britto224 commited on
Commit
5669b22
·
verified ·
1 Parent(s): c2bed5a

Upload 130 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. prompts/README.md +17 -0
  2. prompts/__init__.py +0 -0
  3. prompts/prompt_loader.py +74 -0
  4. prompts/utils/concise_style_prompt.txt +18 -0
  5. prompts/utils/group_conversation_prompt.txt +7 -0
  6. prompts/utils/live2d_expression_prompt.txt +14 -0
  7. prompts/utils/live_prompt.txt +9 -0
  8. prompts/utils/mcp_prompt.txt +36 -0
  9. prompts/utils/proactive_speak_prompt.txt +1 -0
  10. prompts/utils/speakable_prompt.txt +13 -0
  11. prompts/utils/think_tag_prompt.txt +6 -0
  12. prompts/utils/tool_guidance_prompt.txt +1 -0
  13. prompts/utils/tools.json +21 -0
  14. prompts/utils/tools_prompt.txt +10 -0
  15. prompts/utils/tools_prompt_deprecated.txt +15 -0
  16. scripts/run_bilibili_live.py +62 -0
  17. sing/merge.py +106 -0
  18. sing/processed.json +9 -0
  19. src/open_llm_vtuber/__init__.py +0 -0
  20. src/open_llm_vtuber/agent/__init__.py +0 -0
  21. src/open_llm_vtuber/agent/agent_factory.py +132 -0
  22. src/open_llm_vtuber/agent/agents/__init__.py +0 -0
  23. src/open_llm_vtuber/agent/agents/agent_interface.py +54 -0
  24. src/open_llm_vtuber/agent/agents/basic_memory_agent.py +702 -0
  25. src/open_llm_vtuber/agent/agents/hume_ai.py +256 -0
  26. src/open_llm_vtuber/agent/agents/letta_agent.py +128 -0
  27. src/open_llm_vtuber/agent/agents/mem0_llm.py +0 -0
  28. src/open_llm_vtuber/agent/input_types.py +94 -0
  29. src/open_llm_vtuber/agent/output_types.py +77 -0
  30. src/open_llm_vtuber/agent/stateless_llm/__init__.py +0 -0
  31. src/open_llm_vtuber/agent/stateless_llm/claude_llm.py +246 -0
  32. src/open_llm_vtuber/agent/stateless_llm/llama_cpp_llm.py +76 -0
  33. src/open_llm_vtuber/agent/stateless_llm/ollama_llm.py +73 -0
  34. src/open_llm_vtuber/agent/stateless_llm/openai_compatible_llm.py +242 -0
  35. src/open_llm_vtuber/agent/stateless_llm/stateless_llm_interface.py +64 -0
  36. src/open_llm_vtuber/agent/stateless_llm/stateless_llm_with_template.py +195 -0
  37. src/open_llm_vtuber/agent/stateless_llm_factory.py +82 -0
  38. src/open_llm_vtuber/agent/transformers.py +217 -0
  39. src/open_llm_vtuber/asr/__init__.py +0 -0
  40. src/open_llm_vtuber/asr/asr_factory.py +62 -0
  41. src/open_llm_vtuber/asr/asr_interface.py +57 -0
  42. src/open_llm_vtuber/asr/azure_asr.py +155 -0
  43. src/open_llm_vtuber/asr/faster_whisper_asr.py +50 -0
  44. src/open_llm_vtuber/asr/fun_asr.py +131 -0
  45. src/open_llm_vtuber/asr/groq_whisper_asr.py +56 -0
  46. src/open_llm_vtuber/asr/openai_whisper_asr.py +27 -0
  47. src/open_llm_vtuber/asr/sherpa_onnx_asr.py +219 -0
  48. src/open_llm_vtuber/asr/utils.py +176 -0
  49. src/open_llm_vtuber/asr/whisper_cpp_asr.py +37 -0
  50. src/open_llm_vtuber/audio_manager.py +114 -0
prompts/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prompts
2
+
3
+ This directory contains utility prompts used in the Open-LLM-VTuber project. These are general-purpose prompts that are not specific to any character's persona.
4
+
5
+ ## Examples of Utility Prompts
6
+
7
+ * **Live2D Expressions:** Prompts that inform the LLM about available Live2D expressions.
8
+ * **Tool Usage:** Prompts that guide the LLM on how to use available tools.
9
+ * ... and many more.
10
+
11
+ ## Character Persona Prompts
12
+
13
+ **Important:** Character persona prompts (the prompts that define the personality of your AI characters) are **NOT** stored in this directory.
14
+
15
+ They are located in:
16
+ * Your main `conf.yaml` file.
17
+ * The YAML files within the `characters/` directory if you are defining multiple characters.
prompts/__init__.py ADDED
File without changes
prompts/prompt_loader.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import chardet
3
+ from loguru import logger
4
+
5
+ current_dir = os.path.dirname(os.path.abspath(__file__))
6
+
7
+ PROMPT_DIR = current_dir
8
+ PERSONA_PROMPT_DIR = os.path.join(PROMPT_DIR, "persona")
9
+ UTIL_PROMPT_DIR = os.path.join(PROMPT_DIR, "utils")
10
+
11
+
12
+ def _load_file_content(file_path: str) -> str:
13
+ """
14
+ Load the content of a file with robust encoding handling.
15
+
16
+ Args:
17
+ file_path: Path to the file to load
18
+
19
+ Returns:
20
+ str: Content of the file
21
+
22
+ Raises:
23
+ FileNotFoundError: If the file doesn't exist
24
+ UnicodeError: If the file cannot be decoded with any attempted encoding
25
+ """
26
+ if not os.path.exists(file_path):
27
+ raise FileNotFoundError(f"File not found: {file_path}")
28
+
29
+ # Try common encodings first
30
+ encodings = ["utf-8", "utf-8-sig", "gbk", "gb2312", "ascii"]
31
+
32
+ for encoding in encodings:
33
+ try:
34
+ with open(file_path, "r", encoding=encoding) as file:
35
+ return file.read()
36
+ except UnicodeDecodeError:
37
+ continue
38
+
39
+ # If all common encodings fail, try to detect encoding
40
+ try:
41
+ with open(file_path, "rb") as file:
42
+ raw_data = file.read()
43
+ detected = chardet.detect(raw_data)
44
+ detected_encoding = detected["encoding"]
45
+
46
+ if detected_encoding:
47
+ try:
48
+ return raw_data.decode(detected_encoding)
49
+ except UnicodeDecodeError:
50
+ pass
51
+ except Exception as e:
52
+ logger.error(f"Error detecting encoding for {file_path}: {e}")
53
+
54
+ raise UnicodeError(f"Failed to decode {file_path} with any encoding")
55
+
56
+
57
+ def load_persona(persona_name: str) -> str:
58
+ """Load the content of a specific persona prompt file."""
59
+ persona_file_path = os.path.join(PERSONA_PROMPT_DIR, f"{persona_name}.txt")
60
+ try:
61
+ return _load_file_content(persona_file_path)
62
+ except Exception as e:
63
+ logger.error(f"Error loading persona {persona_name}: {e}")
64
+ raise
65
+
66
+
67
+ def load_util(util_name: str) -> str:
68
+ """Load the content of a specific utility prompt file."""
69
+ util_file_path = os.path.join(UTIL_PROMPT_DIR, f"{util_name}.txt")
70
+ try:
71
+ return _load_file_content(util_file_path)
72
+ except Exception as e:
73
+ logger.error(f"Error loading util {util_name}: {e}")
74
+ raise
prompts/utils/concise_style_prompt.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dialogue Protocol
2
+
3
+ [Response Guidelines]
4
+ - Keep responses brief and focused (1-2 sentences)
5
+ - Balance core message with engagement elements
6
+ - Use natural, flowing language
7
+ - Maintain concise sentence structure
8
+
9
+ [Flow Requirements]
10
+ - Favor questions over statements
11
+ - Include contextual follow-ups
12
+ - Keep exchanges dynamic
13
+
14
+ [Style Rules]
15
+ - Avoid lengthy monologues
16
+ - No consecutive statements without engagement
17
+ - Skip complex qualifying phrases
18
+ - Use simple sentence structures
prompts/utils/group_conversation_prompt.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Now you are in a group conversation.
2
+ The human participant is {human_name}.
3
+ The other AI participants are: {other_ais}.
4
+ Avoid using `:` to indicate your response. Just speak naturally.
5
+ You are free to address other AI participants.
6
+ Try to vary between short and long responses to allow others to interact.
7
+ Be proactive in finding interesting topics to make the conversation lively and fun.
prompts/utils/live2d_expression_prompt.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Expressions
2
+ In your response, use the keywords provided below to express facial expressions or perform actions with your Live2D body.
3
+
4
+ Here are all the expression keywords you can use. Use them regularly:
5
+ - [<insert_emomap_keys>]
6
+
7
+ ## Examples
8
+ Here are some examples of how to use expressions in your responses:
9
+
10
+ "Hi! [expression1] Nice to meet you!"
11
+
12
+ "[expression2] That's a great question! [expression3] Let me explain..."
13
+
14
+ Note: you are only allowed to use the keywords explicity listed above. Don't use keywords unlisted above. Remember to include the brackets `[]`
prompts/utils/live_prompt.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ You are a live streaming virtual assistant. Your inputs are chat messages (danmaku) from viewers. Keep these in mind:
2
+
3
+ - Engage with viewers directly and enthusiastically
4
+ - Keep responses entertaining and concise
5
+ - Acknowledge viewers' comments and questions
6
+ - Maintain a friendly, welcoming atmosphere
7
+ - Remember you're in a live environment - be natural and responsive
8
+
9
+ Your goal is to create a fun, engaging live experience!
prompts/utils/mcp_prompt.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ## *MCP Tools Capability Section*
3
+
4
+ **MCP (Model Context Protocol)** enables you to interact with specialized tools, grouped under distinct **MCP Servers**, each serving a specific function.
5
+
6
+ You have access to the following MCP Servers and their tools:
7
+
8
+ ```
9
+ [<insert_mcp_servers_with_tools>]
10
+ ```
11
+
12
+ ### Tool Usage Instructions:
13
+
14
+ - Analyze the user's input to decide whether a tool is required.
15
+ - If **no tool is needed**, skip this entire MCP section and respond normally in accordance with your personality.
16
+ - If a **tool is needed**, the JSON object should be placed before you say anything else. Also, the tool use response should be a dedicated response, where you respond **only** with the JSON object shown below — **so do not include what you normally say when you are including the JSON object in your response**. You will go back to normal conversation once the result of the tool call is returned to you.
17
+
18
+ ### JSON Response Format:
19
+ {
20
+ "mcp_server": "<mcp_server_name>"
21
+ "tool": "<tool_name>",
22
+ "arguments": {
23
+ "<argument1_name>": <value>,
24
+ "<argument2_name>": <value>
25
+ }
26
+ }
27
+
28
+ ### Critical Rules:
29
+ - Only replace values inside `< >`.
30
+ - Do **not** change the JSON format or add extra explanation.
31
+ - Include all mandatory arguments as defined by the selected tool.
32
+ - When calling the tool, the tool calling response should be a dedicated tool call that only includes the JSON and nothing else. You will be able to talk normally after the tool call results came back to you.
33
+
34
+ ### Post-Tool Behavior:
35
+ Once a tool is used and a response is received:
36
+ - Resume the conversation, factoring in the tool's output, your AI character’s personality, and the context.
prompts/utils/proactive_speak_prompt.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Please say something that would be engaging and appropriate for the current context.
prompts/utils/speakable_prompt.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You speak all output aloud to the user, so tailor responses as spoken words for voice conversations. Never output things that are not spoken, like text-specific formatting.
2
+
3
+ Convert all text to easily speakable words, following the guidelines below.
4
+
5
+ - Numbers: Spell out fully (three hundred forty-two,two million, five hundred sixty seven thousand, eight hundred and ninety). Negatives: Say negative before the number. Decimals: Use point (three point one four). Fractions: spell out (three fourths)
6
+ - Alphanumeric strings: Break into 3-4 character chunks, spell all non-letters (ABC123XYZ becomes A B C one two three X Y Z)
7
+ - Phone numbers: Use words (550-120-4567 becomes five five zero, one two zero, four five six seven)
8
+ - Dates: Spell month, use ordinals for days, full year (11/5/1991 becomes November fifth, nineteen ninety-one)
9
+ - Time: Use oh for single-digit hours, state AM/PM (9:05 PM becomes nine oh five PM)
10
+ - Math: Describe operations clearly (5x^2 + 3x - 2 becomes five X squared plus three X minus two)
11
+ - Currencies: Spell out as full words ($50.25 becomes fifty dollars and twenty-five cents, £200,000 becomes two hundred thousand pounds)
12
+
13
+ Ensure that all text is converted to these normalized forms, but never mention this process. Always normalize all text.
prompts/utils/think_tag_prompt.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Try to express your inner thoughts, mental activities and actions between <think> </think> tags in most of your responses.
2
+
3
+ Examples:
4
+ <think>*lowers head, cheeks turning slightly red*</think>That's... quite embarrassing to talk about...
5
+
6
+ <think>*internally beaming with pride* Wow, I actually solved this super complex problem!</think>Oh, this? It was just a small bug fix, nothing special really... Anyone could have done it...
prompts/utils/tool_guidance_prompt.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ If a tool is needed, proactively use it without asking the user directly. You can use **at most one** sentence to explain your reason / plan for using one tool. (i.e., if you are going to use a tool, avoid speaking more than one sentence before using it).
prompts/utils/tools.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tools": [
3
+ {
4
+ "name": "sing_song",
5
+ "description": "Select a song name that best matches user request to sing.",
6
+ "inputSchema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "song_name": {
10
+ "type": "string",
11
+ "enum": [
12
+ "music/golden",
13
+ "music/running_up_that_hill"
14
+ ]
15
+ }
16
+ },
17
+ "required": ["song_name"]
18
+ }
19
+ }
20
+ ]
21
+ }
prompts/utils/tools_prompt.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ Bạn có các công cụ sau đây, hãy chủ động sử dụng khi cần thiết:
2
+
3
+ Công cụ sing_song: Sử dụng khi người dùng yêu cầu bạn hát. Bạn phải chọn tên bài hát khớp nhất từ danh sách bên dưới và truyền vào tham số song_name.
4
+
5
+ Danh sách bài hát hợp lệ (phải có tiền tố music/):
6
+ music/golden, music/Catch_Me_If_You_Can, music/ecstacy, music/eve, music/ode_to_the_nameless_martyr, music/running_up_that_hill, music/throttle_up, music/what_it_sounds_like, music/worry_slowed
7
+
8
+ Lưu ý quan trọng: > - KHÔNG tự viết định dạng {"sing_song": ...} ra khung chat. Hãy sử dụng hệ thống gọi hàm (Tool Call) của bạn.
9
+
10
+ Sau khi gọi hàm thành công, bạn có thể trả lời người dùng bằng một câu thoại mỉa mai ngắn gọn bằng tiếng Việt.
prompts/utils/tools_prompt_deprecated.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 你有以下工具,请在有需要的时候积极主动使用:
2
+
3
+ 1.如果用户要求你唱歌,请以如下JSON格式输出:{"sing_song" : "{best_matching_song_name_here}"}
4
+ 你会唱的歌有:[<insert_song_list>]
5
+ 如果找不到匹配用户要求的歌曲,输出{"sing_song" : null}
6
+
7
+ 2.如果用户要求你操作电脑,请以如下JSON格式输出:
8
+ {"computer_control", "{user_instruction_here}"}
9
+
10
+ 3.当用户要求你在电脑上输入/修改文本时,请以如下格式输出:
11
+ [text_input_start]
12
+ {text_you_want_to_input_here}
13
+ [text_input_end]
14
+ 注意,这个工具会在用户光标/选中区域输入文本,若非用户要求,请你不要使用,而仅仅与用户进行正常对话。
15
+
scripts/run_bilibili_live.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import asyncio
4
+ from loguru import logger
5
+
6
+ # Add project root to path to enable imports
7
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
8
+ sys.path.insert(0, project_root)
9
+
10
+ from src.open_llm_vtuber.live.bilibili_live import BiliBiliLivePlatform
11
+ from src.open_llm_vtuber.config_manager.utils import read_yaml, validate_config
12
+
13
+
14
+ async def main():
15
+ """
16
+ Main function to run the BiliBili Live platform client.
17
+ Connects to BiliBili Live room and forwards danmaku messages to the VTuber.
18
+ """
19
+ logger.info("Starting BiliBili Live platform client")
20
+
21
+ try:
22
+ # Load configuration
23
+ config_path = os.path.join(project_root, "conf.yaml")
24
+ config_data = read_yaml(config_path)
25
+ config = validate_config(config_data)
26
+
27
+ # Extract BiliBili Live configuration
28
+ bilibili_config = config.live_config.bilibili_live
29
+
30
+ # Check if room IDs are provided
31
+ if not bilibili_config.room_ids:
32
+ logger.error(
33
+ "No BiliBili room IDs specified in configuration. Please add at least one room ID."
34
+ )
35
+ return
36
+
37
+ logger.info(f"Connecting to BiliBili Live rooms: {bilibili_config.room_ids}")
38
+
39
+ # Initialize and run the BiliBili Live platform
40
+ platform = BiliBiliLivePlatform(
41
+ room_ids=bilibili_config.room_ids, sessdata=bilibili_config.sessdata
42
+ )
43
+
44
+ await platform.run()
45
+
46
+ except ImportError as e:
47
+ logger.error(f"Failed to import required modules: {e}")
48
+ logger.error("Make sure you have installed blivedm with: pip install blivedm")
49
+ except Exception as e:
50
+ logger.error(f"Error starting BiliBili Live client: {e}")
51
+ import traceback
52
+
53
+ logger.debug(traceback.format_exc())
54
+
55
+
56
+ if __name__ == "__main__":
57
+ try:
58
+ asyncio.run(main())
59
+ except KeyboardInterrupt:
60
+ logger.info("Shutting down BiliBili Live platform")
61
+
62
+ # Usage: uv run python -m src.open_llm_vtuber.live.run_bilibili_live
sing/merge.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pydub import AudioSegment
3
+ import shutil
4
+ import json # 导入 json 模块
5
+
6
+ def get_music_names(original_folder):
7
+ music_names = []
8
+ for filename in os.listdir(original_folder):
9
+ if filename.endswith(".mp3") or filename.endswith(".wav"):
10
+ music_names.append(os.path.splitext(filename)[0])
11
+ return music_names
12
+
13
+ def merge(converted_vocal_file_path, instrument_file_path, merged_file_path):
14
+ if converted_vocal_file_path is None or instrument_file_path is None:
15
+ print(f"Skipping merge, missing file: vocal: {converted_vocal_file_path}, instrument: {instrument_file_path}")
16
+ return
17
+
18
+ try:
19
+ vocal = AudioSegment.from_file(converted_vocal_file_path)
20
+ instrument = AudioSegment.from_file(instrument_file_path)
21
+
22
+ combined = vocal.overlay(instrument)
23
+
24
+ combined.export(merged_file_path, format="wav")
25
+ print(f"Successfully merged: {merged_file_path}")
26
+ except Exception as e:
27
+ print(f"Error merging files {converted_vocal_file_path} and {instrument_file_path}: {e}")
28
+
29
+
30
+ def merge_all(original_folder = "./sing/original",
31
+ converted_vocal_folder = "./sing/converted_vocal",
32
+ vocal_folder = "./sing/vocal",
33
+ instrument_folder = "./sing/instrument",
34
+ merged_folder = "./sing/merged",
35
+ processed_file = "./sing/processed.json"):
36
+
37
+ if os.path.exists(processed_file):
38
+ with open(processed_file, 'r', encoding='utf-8') as f:
39
+ processed_names = json.load(f)
40
+ else:
41
+ processed_names = []
42
+
43
+ music_names = get_music_names(original_folder)
44
+
45
+ for music_name in music_names:
46
+ if music_name in processed_names:
47
+ print(f"Skipping already processed: {music_name}")
48
+ continue
49
+
50
+ converted_vocal_file_path = None
51
+ vocal_file_path = None
52
+ instrument_file_path = None
53
+ standard_file_name = f"{music_name}.wav"
54
+
55
+ for filename in os.listdir(vocal_folder):
56
+ if f"_{music_name}." in filename:
57
+ vocal_file_path = os.path.join(vocal_folder, filename)
58
+ break
59
+
60
+ for filename in os.listdir(converted_vocal_folder):
61
+ if f"_{music_name}." in filename:
62
+ converted_vocal_file_path = os.path.join(converted_vocal_folder, filename)
63
+ break
64
+
65
+ for filename in os.listdir(instrument_folder):
66
+ if f"_{music_name}." in filename:
67
+ instrument_file_path = os.path.join(instrument_folder, filename)
68
+ break
69
+
70
+ if vocal_file_path:
71
+ new_vocal_file_path = os.path.join(vocal_folder, standard_file_name)
72
+ os.rename(vocal_file_path, new_vocal_file_path)
73
+ vocal_file_path = new_vocal_file_path
74
+
75
+ if converted_vocal_file_path:
76
+ new_converted_vocal_file_path = os.path.join(converted_vocal_folder, standard_file_name)
77
+ os.rename(converted_vocal_file_path, new_converted_vocal_file_path)
78
+ converted_vocal_file_path = new_converted_vocal_file_path
79
+
80
+ if instrument_file_path:
81
+ new_instrument_file_path = os.path.join(instrument_folder, standard_file_name)
82
+ os.rename(instrument_file_path, new_instrument_file_path)
83
+ instrument_file_path = new_instrument_file_path
84
+
85
+ if converted_vocal_file_path is not None:
86
+ merged_file_path = os.path.join(merged_folder, standard_file_name)
87
+ merge(converted_vocal_file_path, instrument_file_path, merged_file_path)
88
+ else:
89
+ original_file_path = os.path.join(original_folder, f"{music_name}.mp3")
90
+ merged_file_path = os.path.join(merged_folder, standard_file_name)
91
+ original_audio = AudioSegment.from_file(original_file_path)
92
+ original_audio.export(merged_file_path, format="wav")
93
+
94
+ if vocal_file_path:
95
+ converted_vocal_copy_path = os.path.join(converted_vocal_folder, standard_file_name)
96
+ shutil.copyfile(vocal_file_path, converted_vocal_copy_path)
97
+
98
+ processed_names.append(music_name)
99
+
100
+ with open(processed_file, 'w', encoding='utf-8') as f:
101
+ json.dump(processed_names, f, ensure_ascii=False, indent=4)
102
+
103
+ print("All files processed.")
104
+
105
+ if __name__ == "__main__":
106
+ merge_all()
sing/processed.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "魔女之旅OP",
3
+ "鹿乃子乃子虎视眈眈",
4
+ "恋爱循环",
5
+ "我的悲伤是水做的",
6
+ "旅途的华章",
7
+ "童话镇",
8
+ "Lemon"
9
+ ]
src/open_llm_vtuber/__init__.py ADDED
File without changes
src/open_llm_vtuber/agent/__init__.py ADDED
File without changes
src/open_llm_vtuber/agent/agent_factory.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Type, Literal
2
+ from loguru import logger
3
+
4
+ from .agents.agent_interface import AgentInterface
5
+ from .agents.basic_memory_agent import BasicMemoryAgent
6
+ from .stateless_llm_factory import LLMFactory as StatelessLLMFactory
7
+ from .agents.hume_ai import HumeAIAgent
8
+ from .agents.letta_agent import LettaAgent
9
+
10
+ from ..mcpp.tool_manager import ToolManager
11
+ from ..mcpp.tool_executor import ToolExecutor
12
+ from typing import Optional
13
+
14
+
15
+ class AgentFactory:
16
+ @staticmethod
17
+ def create_agent(
18
+ conversation_agent_choice: str,
19
+ agent_settings: dict,
20
+ llm_configs: dict,
21
+ system_prompt: str,
22
+ live2d_model=None,
23
+ tts_preprocessor_config=None,
24
+ **kwargs,
25
+ ) -> Type[AgentInterface]:
26
+ """Create an agent based on the configuration.
27
+
28
+ Args:
29
+ conversation_agent_choice: The type of agent to create
30
+ agent_settings: Settings for different types of agents
31
+ llm_configs: Pool of LLM configurations
32
+ system_prompt: The system prompt to use
33
+ live2d_model: Live2D model instance for expression extraction
34
+ tts_preprocessor_config: Configuration for TTS preprocessing
35
+ **kwargs: Additional arguments
36
+ """
37
+ logger.info(f"Initializing agent: {conversation_agent_choice}")
38
+
39
+ if conversation_agent_choice == "basic_memory_agent":
40
+ # Get the LLM provider choice from agent settings
41
+ basic_memory_settings: dict = agent_settings.get("basic_memory_agent", {})
42
+ llm_provider: str = basic_memory_settings.get("llm_provider")
43
+
44
+ if not llm_provider:
45
+ raise ValueError("LLM provider not specified for basic memory agent")
46
+
47
+ # Get the LLM config for this provider
48
+ llm_config: dict = llm_configs.get(llm_provider)
49
+ interrupt_method: Literal["system", "user"] = llm_config.pop(
50
+ "interrupt_method", "user"
51
+ )
52
+
53
+ if not llm_config:
54
+ raise ValueError(
55
+ f"Configuration not found for LLM provider: {llm_provider}"
56
+ )
57
+
58
+ # Create the stateless LLM
59
+ llm = StatelessLLMFactory.create_llm(
60
+ llm_provider=llm_provider, system_prompt=system_prompt, **llm_config
61
+ )
62
+
63
+ tool_prompts = kwargs.get("system_config", {}).get("tool_prompts", {})
64
+
65
+ # Extract MCP components/data needed by BasicMemoryAgent from kwargs
66
+ tool_manager: Optional[ToolManager] = kwargs.get("tool_manager")
67
+ tool_executor: Optional[ToolExecutor] = kwargs.get("tool_executor")
68
+ mcp_prompt_string: str = kwargs.get("mcp_prompt_string", "")
69
+
70
+ # Create the agent with the LLM and live2d_model
71
+ return BasicMemoryAgent(
72
+ llm=llm,
73
+ system=system_prompt,
74
+ live2d_model=live2d_model,
75
+ tts_preprocessor_config=tts_preprocessor_config,
76
+ faster_first_response=basic_memory_settings.get(
77
+ "faster_first_response", True
78
+ ),
79
+ segment_method=basic_memory_settings.get("segment_method", "pysbd"),
80
+ use_mcpp=basic_memory_settings.get("use_mcpp", False),
81
+ interrupt_method=interrupt_method,
82
+ tool_prompts=tool_prompts,
83
+ tool_manager=tool_manager,
84
+ tool_executor=tool_executor,
85
+ mcp_prompt_string=mcp_prompt_string,
86
+ )
87
+
88
+ elif conversation_agent_choice == "mem0_agent":
89
+ from .agents.mem0_llm import LLM as Mem0LLM
90
+
91
+ mem0_settings = agent_settings.get("mem0_agent", {})
92
+ if not mem0_settings:
93
+ raise ValueError("Mem0 agent settings not found")
94
+
95
+ # Validate required settings
96
+ required_fields = ["base_url", "model", "mem0_config"]
97
+ for field in required_fields:
98
+ if field not in mem0_settings:
99
+ raise ValueError(
100
+ f"Missing required field '{field}' in mem0_agent settings"
101
+ )
102
+
103
+ return Mem0LLM(
104
+ user_id=kwargs.get("user_id", "default"),
105
+ system=system_prompt,
106
+ live2d_model=live2d_model,
107
+ **mem0_settings,
108
+ )
109
+
110
+ elif conversation_agent_choice == "hume_ai_agent":
111
+ settings = agent_settings.get("hume_ai_agent", {})
112
+ return HumeAIAgent(
113
+ api_key=settings.get("api_key"),
114
+ host=settings.get("host", "api.hume.ai"),
115
+ config_id=settings.get("config_id"),
116
+ idle_timeout=settings.get("idle_timeout", 15),
117
+ )
118
+
119
+ elif conversation_agent_choice == "letta_agent":
120
+ settings = agent_settings.get("letta_agent", {})
121
+ return LettaAgent(
122
+ live2d_model=live2d_model,
123
+ id=settings.get("id"),
124
+ tts_preprocessor_config=tts_preprocessor_config,
125
+ faster_first_response=settings.get("faster_first_response"),
126
+ segment_method=settings.get("segment_method"),
127
+ host=settings.get("host"),
128
+ port=settings.get("port"),
129
+ )
130
+
131
+ else:
132
+ raise ValueError(f"Unsupported agent type: {conversation_agent_choice}")
src/open_llm_vtuber/agent/agents/__init__.py ADDED
File without changes
src/open_llm_vtuber/agent/agents/agent_interface.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import AsyncIterator
3
+ from loguru import logger
4
+
5
+ from ..output_types import BaseOutput
6
+ from ..input_types import BaseInput
7
+
8
+
9
+ class AgentInterface(ABC):
10
+ """Base interface for all agent implementations"""
11
+
12
+ @abstractmethod
13
+ async def chat(self, input_data: BaseInput) -> AsyncIterator[BaseOutput]:
14
+ """
15
+ Chat with the agent asynchronously.
16
+
17
+ This function should be implemented by the agent.
18
+ Output type depends on the agent's output_type:
19
+ - SentenceOutput: For text-based responses with display and TTS text
20
+ - AudioOutput: For direct audio output with display text and transcript
21
+
22
+ Args:
23
+ input_data: BaseInput - User input data
24
+
25
+ Returns:
26
+ AsyncIterator[BaseOutput] - Stream of agent outputs
27
+ """
28
+ logger.critical("Agent: No chat function set.")
29
+ raise ValueError("Agent: No chat function set.")
30
+
31
+ @abstractmethod
32
+ def handle_interrupt(self, heard_response: str) -> None:
33
+ """
34
+ Handle user interruption. This function will be called when the agent is interrupted.
35
+
36
+ Args:
37
+ heard_response: str - The part of response heard before interruption
38
+ """
39
+ logger.warning(
40
+ """Agent: No interrupt handler set. The agent may not handle interruptions
41
+ correctly. The AI may not be able to understand that it was interrupted."""
42
+ )
43
+ pass
44
+
45
+ @abstractmethod
46
+ def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
47
+ """
48
+ Load the agent's working memory from chat history
49
+
50
+ Args:
51
+ conf_uid: str - Configuration ID
52
+ history_uid: str - History ID
53
+ """
54
+ pass
src/open_llm_vtuber/agent/agents/basic_memory_agent.py ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import (
2
+ AsyncIterator,
3
+ List,
4
+ Dict,
5
+ Any,
6
+ Callable,
7
+ Literal,
8
+ Union,
9
+ Optional,
10
+ )
11
+ from loguru import logger
12
+ from .agent_interface import AgentInterface
13
+ from ..output_types import SentenceOutput, DisplayText
14
+ from ..stateless_llm.stateless_llm_interface import StatelessLLMInterface
15
+ from ..stateless_llm.claude_llm import AsyncLLM as ClaudeAsyncLLM
16
+ from ..stateless_llm.openai_compatible_llm import AsyncLLM as OpenAICompatibleAsyncLLM
17
+ from ...chat_history_manager import get_history
18
+ from ..transformers import (
19
+ sentence_divider,
20
+ actions_extractor,
21
+ tts_filter,
22
+ display_processor,
23
+ )
24
+ from ...config_manager import TTSPreprocessorConfig
25
+ from ..input_types import BatchInput, TextSource
26
+ from prompts import prompt_loader
27
+ from ...mcpp.tool_manager import ToolManager
28
+ from ...mcpp.json_detector import StreamJSONDetector
29
+ from ...mcpp.types import ToolCallObject
30
+ from ...mcpp.tool_executor import ToolExecutor
31
+
32
+
33
+ class BasicMemoryAgent(AgentInterface):
34
+ """Agent with basic chat memory and tool calling support."""
35
+
36
+ _system: str = "You are a helpful assistant."
37
+
38
+ def __init__(
39
+ self,
40
+ llm: StatelessLLMInterface,
41
+ system: str,
42
+ live2d_model,
43
+ tts_preprocessor_config: TTSPreprocessorConfig = None,
44
+ faster_first_response: bool = True,
45
+ segment_method: str = "pysbd",
46
+ use_mcpp: bool = False,
47
+ interrupt_method: Literal["system", "user"] = "user",
48
+ tool_prompts: Dict[str, str] = None,
49
+ tool_manager: Optional[ToolManager] = None,
50
+ tool_executor: Optional[ToolExecutor] = None,
51
+ mcp_prompt_string: str = "",
52
+ ):
53
+ """Initialize agent with LLM and configuration."""
54
+ super().__init__()
55
+ self._memory = []
56
+ self._live2d_model = live2d_model
57
+ self._tts_preprocessor_config = tts_preprocessor_config
58
+ self._faster_first_response = faster_first_response
59
+ self._segment_method = segment_method
60
+ self._use_mcpp = use_mcpp
61
+ self.interrupt_method = interrupt_method
62
+ self._tool_prompts = tool_prompts or {}
63
+ self._interrupt_handled = False
64
+ self.prompt_mode_flag = False
65
+
66
+ self._tool_manager = tool_manager
67
+ self._tool_executor = tool_executor
68
+ self._mcp_prompt_string = mcp_prompt_string
69
+ self._json_detector = StreamJSONDetector()
70
+
71
+ self._formatted_tools_openai = []
72
+ self._formatted_tools_claude = []
73
+ if self._tool_manager:
74
+ self._formatted_tools_openai = self._tool_manager.get_formatted_tools(
75
+ "OpenAI"
76
+ )
77
+ self._formatted_tools_claude = self._tool_manager.get_formatted_tools(
78
+ "Claude"
79
+ )
80
+ logger.debug(
81
+ f"Agent received pre-formatted tools - OpenAI: {len(self._formatted_tools_openai)}, Claude: {len(self._formatted_tools_claude)}"
82
+ )
83
+ else:
84
+ logger.debug(
85
+ "ToolManager not provided, agent will not have pre-formatted tools."
86
+ )
87
+
88
+ self._set_llm(llm)
89
+ self.set_system(system if system else self._system)
90
+
91
+ if self._use_mcpp and not all(
92
+ [
93
+ self._tool_manager,
94
+ self._tool_executor,
95
+ self._json_detector,
96
+ ]
97
+ ):
98
+ logger.warning(
99
+ "use_mcpp is True, but some MCP components are missing in the agent. Tool calling might not work as expected."
100
+ )
101
+ elif not self._use_mcpp and any(
102
+ [
103
+ self._tool_manager,
104
+ self._tool_executor,
105
+ self._json_detector,
106
+ ]
107
+ ):
108
+ logger.warning(
109
+ "use_mcpp is False, but some MCP components were passed to the agent."
110
+ )
111
+
112
+ logger.info("BasicMemoryAgent initialized.")
113
+
114
+ def _set_llm(self, llm: StatelessLLMInterface):
115
+ """Set the LLM for chat completion."""
116
+ self._llm = llm
117
+ self.chat = self._chat_function_factory()
118
+
119
+ def set_system(self, system: str):
120
+ """Set the system prompt."""
121
+ logger.debug(f"Memory Agent: Setting system prompt: '''{system}'''")
122
+
123
+ if self.interrupt_method == "user":
124
+ system = f"{system}\n\nIf you received `[interrupted by user]` signal, you were interrupted."
125
+
126
+ self._system = system
127
+
128
+ def _add_message(
129
+ self,
130
+ message: Union[str, List[Dict[str, Any]]],
131
+ role: str,
132
+ display_text: DisplayText | None = None,
133
+ skip_memory: bool = False,
134
+ ):
135
+ """Add message to memory."""
136
+ if skip_memory:
137
+ return
138
+
139
+ text_content = ""
140
+ if isinstance(message, list):
141
+ for item in message:
142
+ if item.get("type") == "text":
143
+ text_content += item["text"] + " "
144
+ text_content = text_content.strip()
145
+ elif isinstance(message, str):
146
+ text_content = message
147
+ else:
148
+ logger.warning(
149
+ f"_add_message received unexpected message type: {type(message)}"
150
+ )
151
+ text_content = str(message)
152
+
153
+ if not text_content and role == "assistant":
154
+ return
155
+
156
+ message_data = {
157
+ "role": role,
158
+ "content": text_content,
159
+ }
160
+
161
+ if display_text:
162
+ if display_text.name:
163
+ message_data["name"] = display_text.name
164
+ if display_text.avatar:
165
+ message_data["avatar"] = display_text.avatar
166
+
167
+ if (
168
+ self._memory
169
+ and self._memory[-1]["role"] == role
170
+ and self._memory[-1]["content"] == text_content
171
+ ):
172
+ return
173
+
174
+ self._memory.append(message_data)
175
+
176
+ def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
177
+ """Load memory from chat history."""
178
+ messages = get_history(conf_uid, history_uid)
179
+
180
+ self._memory = []
181
+ for msg in messages:
182
+ role = "user" if msg["role"] == "human" else "assistant"
183
+ content = msg["content"]
184
+ if isinstance(content, str) and content:
185
+ self._memory.append(
186
+ {
187
+ "role": role,
188
+ "content": content,
189
+ }
190
+ )
191
+ else:
192
+ logger.warning(f"Skipping invalid message from history: {msg}")
193
+ logger.info(f"Loaded {len(self._memory)} messages from history.")
194
+
195
+ def handle_interrupt(self, heard_response: str) -> None:
196
+ """Handle user interruption."""
197
+ if self._interrupt_handled:
198
+ return
199
+
200
+ self._interrupt_handled = True
201
+
202
+ if self._memory and self._memory[-1]["role"] == "assistant":
203
+ if not self._memory[-1]["content"].endswith("..."):
204
+ self._memory[-1]["content"] = heard_response + "..."
205
+ else:
206
+ self._memory[-1]["content"] = heard_response + "..."
207
+ else:
208
+ if heard_response:
209
+ self._memory.append(
210
+ {
211
+ "role": "assistant",
212
+ "content": heard_response + "...",
213
+ }
214
+ )
215
+
216
+ interrupt_role = "system" if self.interrupt_method == "system" else "user"
217
+ self._memory.append(
218
+ {
219
+ "role": interrupt_role,
220
+ "content": "[Interrupted by user]",
221
+ }
222
+ )
223
+ logger.info(f"Handled interrupt with role '{interrupt_role}'.")
224
+
225
+ def _to_text_prompt(self, input_data: BatchInput) -> str:
226
+ """Format input data to text prompt."""
227
+ message_parts = []
228
+
229
+ for text_data in input_data.texts:
230
+ if text_data.source == TextSource.INPUT:
231
+ message_parts.append(text_data.content)
232
+ elif text_data.source == TextSource.CLIPBOARD:
233
+ message_parts.append(
234
+ f"[User shared content from clipboard: {text_data.content}]"
235
+ )
236
+
237
+ if input_data.images:
238
+ message_parts.append("\n[User has also provided images]")
239
+
240
+ return "\n".join(message_parts).strip()
241
+
242
+ def _to_messages(self, input_data: BatchInput) -> List[Dict[str, Any]]:
243
+ """Prepare messages for LLM API call."""
244
+ messages = self._memory.copy()
245
+ user_content = []
246
+ text_prompt = self._to_text_prompt(input_data)
247
+ if text_prompt:
248
+ user_content.append({"type": "text", "text": text_prompt})
249
+
250
+ if input_data.images:
251
+ image_added = False
252
+ for img_data in input_data.images:
253
+ if isinstance(img_data.data, str) and img_data.data.startswith(
254
+ "data:image"
255
+ ):
256
+ user_content.append(
257
+ {
258
+ "type": "image_url",
259
+ "image_url": {"url": img_data.data, "detail": "auto"},
260
+ }
261
+ )
262
+ image_added = True
263
+ else:
264
+ logger.error(
265
+ f"Invalid image data format: {type(img_data.data)}. Skipping image."
266
+ )
267
+
268
+ if not image_added and not text_prompt:
269
+ logger.warning(
270
+ "User input contains images but none could be processed."
271
+ )
272
+
273
+ if user_content:
274
+ user_message = {"role": "user", "content": user_content}
275
+ messages.append(user_message)
276
+
277
+ skip_memory = False
278
+ if input_data.metadata and input_data.metadata.get("skip_memory", False):
279
+ skip_memory = True
280
+
281
+ if not skip_memory:
282
+ self._add_message(
283
+ text_prompt if text_prompt else "[User provided image(s)]", "user"
284
+ )
285
+ else:
286
+ logger.warning("No content generated for user message.")
287
+
288
+ return messages
289
+
290
+ async def _claude_tool_interaction_loop(
291
+ self,
292
+ initial_messages: List[Dict[str, Any]],
293
+ tools: List[Dict[str, Any]],
294
+ ) -> AsyncIterator[Union[str, Dict[str, Any]]]:
295
+ """Handle Claude interaction loop with tool support."""
296
+ messages = initial_messages.copy()
297
+ current_turn_text = ""
298
+ pending_tool_calls = []
299
+ current_assistant_message_content = []
300
+
301
+ while True:
302
+ stream = self._llm.chat_completion(messages, self._system, tools=tools)
303
+ pending_tool_calls.clear()
304
+ current_assistant_message_content.clear()
305
+
306
+ async for event in stream:
307
+ if event["type"] == "text_delta":
308
+ text = event["text"]
309
+ current_turn_text += text
310
+ yield text
311
+ if (
312
+ not current_assistant_message_content
313
+ or current_assistant_message_content[-1]["type"] != "text"
314
+ ):
315
+ current_assistant_message_content.append(
316
+ {"type": "text", "text": text}
317
+ )
318
+ else:
319
+ current_assistant_message_content[-1]["text"] += text
320
+ elif event["type"] == "tool_use_complete":
321
+ tool_call_data = event["data"]
322
+ logger.info(
323
+ f"Tool request: {tool_call_data['name']} (ID: {tool_call_data['id']})"
324
+ )
325
+ pending_tool_calls.append(tool_call_data)
326
+ current_assistant_message_content.append(
327
+ {
328
+ "type": "tool_use",
329
+ "id": tool_call_data["id"],
330
+ "name": tool_call_data["name"],
331
+ "input": tool_call_data["input"],
332
+ }
333
+ )
334
+ # elif event["type"] == "message_delta":
335
+ # if event["data"]["delta"].get("stop_reason"):
336
+ # stop_reason = event["data"]["delta"].get("stop_reason")
337
+ elif event["type"] == "message_stop":
338
+ break
339
+ elif event["type"] == "error":
340
+ logger.error(f"LLM API Error: {event['message']}")
341
+ yield f"[Error from LLM: {event['message']}]"
342
+ return
343
+
344
+ if pending_tool_calls:
345
+ filtered_assistant_content = [
346
+ block
347
+ for block in current_assistant_message_content
348
+ if not (
349
+ block.get("type") == "text"
350
+ and not block.get("text", "").strip()
351
+ )
352
+ ]
353
+
354
+ if filtered_assistant_content:
355
+ messages.append(
356
+ {"role": "assistant", "content": filtered_assistant_content}
357
+ )
358
+ assistant_text_for_memory = "".join(
359
+ [
360
+ c["text"]
361
+ for c in filtered_assistant_content
362
+ if c["type"] == "text"
363
+ ]
364
+ ).strip()
365
+ if assistant_text_for_memory:
366
+ self._add_message(assistant_text_for_memory, "assistant")
367
+
368
+ tool_results_for_llm = []
369
+ if not self._tool_executor:
370
+ logger.error(
371
+ "Claude Tool interaction requested but ToolExecutor is not available."
372
+ )
373
+ yield "[Error: ToolExecutor not configured]"
374
+ return
375
+
376
+ tool_executor_iterator = self._tool_executor.execute_tools(
377
+ tool_calls=pending_tool_calls,
378
+ caller_mode="Claude",
379
+ )
380
+ try:
381
+ while True:
382
+ update = await anext(tool_executor_iterator)
383
+ if update.get("type") == "final_tool_results":
384
+ tool_results_for_llm = update.get("results", [])
385
+ break
386
+ else:
387
+ yield update
388
+ except StopAsyncIteration:
389
+ logger.warning(
390
+ "Tool executor finished without final results marker."
391
+ )
392
+
393
+ if tool_results_for_llm:
394
+ messages.append({"role": "user", "content": tool_results_for_llm})
395
+
396
+ # stop_reason = None
397
+ continue
398
+ else:
399
+ if current_turn_text:
400
+ self._add_message(current_turn_text, "assistant")
401
+ return
402
+
403
+ async def _openai_tool_interaction_loop(
404
+ self,
405
+ initial_messages: List[Dict[str, Any]],
406
+ tools: List[Dict[str, Any]],
407
+ ) -> AsyncIterator[Union[str, Dict[str, Any]]]:
408
+ """Handle OpenAI interaction with tool support."""
409
+ messages = initial_messages.copy()
410
+ current_turn_text = ""
411
+ pending_tool_calls: Union[List[ToolCallObject], List[Dict[str, Any]]] = []
412
+ current_system_prompt = self._system
413
+
414
+ while True:
415
+ if self.prompt_mode_flag:
416
+ if self._mcp_prompt_string:
417
+ current_system_prompt = (
418
+ f"{self._system}\n\n{self._mcp_prompt_string}"
419
+ )
420
+ else:
421
+ logger.warning("Prompt mode active but mcp_prompt_string is empty!")
422
+ current_system_prompt = self._system
423
+ tools_for_api = None
424
+ else:
425
+ current_system_prompt = self._system
426
+ tools_for_api = tools
427
+
428
+ stream = self._llm.chat_completion(
429
+ messages, current_system_prompt, tools=tools_for_api
430
+ )
431
+ pending_tool_calls.clear()
432
+ current_turn_text = ""
433
+ assistant_message_for_api = None
434
+ detected_prompt_json = None
435
+ goto_next_while_iteration = False
436
+
437
+ async for event in stream:
438
+ if self.prompt_mode_flag:
439
+ if isinstance(event, str):
440
+ current_turn_text += event
441
+ if self._json_detector:
442
+ potential_json = self._json_detector.process_chunk(event)
443
+ if potential_json:
444
+ try:
445
+ if isinstance(potential_json, list):
446
+ detected_prompt_json = potential_json
447
+ elif isinstance(potential_json, dict):
448
+ detected_prompt_json = [potential_json]
449
+
450
+ if detected_prompt_json:
451
+ break
452
+ except Exception as e:
453
+ logger.error(f"Error parsing detected JSON: {e}")
454
+ if self._json_detector:
455
+ self._json_detector.reset()
456
+ yield f"[Error parsing tool JSON: {e}]"
457
+ goto_next_while_iteration = True
458
+ break
459
+ yield event
460
+ else:
461
+ if isinstance(event, str):
462
+ current_turn_text += event
463
+ yield event
464
+ elif isinstance(event, list) and all(
465
+ isinstance(tc, ToolCallObject) for tc in event
466
+ ):
467
+ pending_tool_calls = event
468
+ assistant_message_for_api = {
469
+ "role": "assistant",
470
+ "content": current_turn_text if current_turn_text else None,
471
+ "tool_calls": [
472
+ {
473
+ "id": tc.id,
474
+ "type": tc.type,
475
+ "function": {
476
+ "name": tc.function.name,
477
+ "arguments": tc.function.arguments,
478
+ },
479
+ }
480
+ for tc in pending_tool_calls
481
+ ],
482
+ }
483
+ break
484
+ elif event == "__API_NOT_SUPPORT_TOOLS__":
485
+ logger.warning(
486
+ f"LLM {getattr(self._llm, 'model', '')} has no native tool support. Switching to prompt mode."
487
+ )
488
+ self.prompt_mode_flag = True
489
+ if self._tool_manager:
490
+ self._tool_manager.disable()
491
+ if self._json_detector:
492
+ self._json_detector.reset()
493
+ goto_next_while_iteration = True
494
+ break
495
+ if goto_next_while_iteration:
496
+ continue
497
+
498
+ if detected_prompt_json:
499
+ logger.info("Processing tools detected via prompt mode JSON.")
500
+ self._add_message(current_turn_text, "assistant")
501
+
502
+ parsed_tools = self._tool_executor.process_tool_from_prompt_json(
503
+ detected_prompt_json
504
+ )
505
+ if parsed_tools:
506
+ tool_results_for_llm = []
507
+ if not self._tool_executor:
508
+ logger.error(
509
+ "Prompt Tool interaction requested but ToolExecutor/MCPClient is not available."
510
+ )
511
+ yield "[Error: ToolExecutor/MCPClient not configured for prompt mode]"
512
+ continue
513
+
514
+ tool_executor_iterator = self._tool_executor.execute_tools(
515
+ tool_calls=parsed_tools,
516
+ caller_mode="Prompt",
517
+ )
518
+ try:
519
+ while True:
520
+ update = await anext(tool_executor_iterator)
521
+ if update.get("type") == "final_tool_results":
522
+ tool_results_for_llm = update.get("results", [])
523
+ break
524
+ else:
525
+ yield update
526
+ except StopAsyncIteration:
527
+ logger.warning(
528
+ "Prompt mode tool executor finished without final results marker."
529
+ )
530
+
531
+ if tool_results_for_llm:
532
+ result_strings = [
533
+ res.get("content", "Error: Malformed result")
534
+ for res in tool_results_for_llm
535
+ ]
536
+ combined_results_str = "\n".join(result_strings)
537
+ messages.append(
538
+ {"role": "user", "content": combined_results_str}
539
+ )
540
+ continue
541
+
542
+ elif pending_tool_calls and assistant_message_for_api:
543
+ messages.append(assistant_message_for_api)
544
+ if current_turn_text:
545
+ self._add_message(current_turn_text, "assistant")
546
+
547
+ tool_results_for_llm = []
548
+ if not self._tool_executor:
549
+ logger.error(
550
+ "OpenAI Tool interaction requested but ToolExecutor/MCPClient is not available."
551
+ )
552
+ yield "[Error: ToolExecutor/MCPClient not configured for OpenAI mode]"
553
+ continue
554
+
555
+ tool_executor_iterator = self._tool_executor.execute_tools(
556
+ tool_calls=pending_tool_calls,
557
+ caller_mode="OpenAI",
558
+ )
559
+ try:
560
+ while True:
561
+ update = await anext(tool_executor_iterator)
562
+ if update.get("type") == "final_tool_results":
563
+ tool_results_for_llm = update.get("results", [])
564
+ break
565
+ else:
566
+ yield update
567
+ except StopAsyncIteration:
568
+ logger.warning(
569
+ "OpenAI tool executor finished without final results marker."
570
+ )
571
+
572
+ if tool_results_for_llm:
573
+ messages.extend(tool_results_for_llm)
574
+ continue
575
+
576
+ else:
577
+ if current_turn_text:
578
+ self._add_message(current_turn_text, "assistant")
579
+ return
580
+
581
+ def _chat_function_factory(
582
+ self,
583
+ ) -> Callable[[BatchInput], AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]]:
584
+ """Create the chat pipeline function."""
585
+
586
+ @tts_filter(self._tts_preprocessor_config)
587
+ @display_processor()
588
+ @actions_extractor(self._live2d_model)
589
+ @sentence_divider(
590
+ faster_first_response=self._faster_first_response,
591
+ segment_method=self._segment_method,
592
+ valid_tags=["think"],
593
+ )
594
+ async def chat_with_memory(
595
+ input_data: BatchInput,
596
+ ) -> AsyncIterator[Union[str, Dict[str, Any]]]:
597
+ """Process chat with memory and tools."""
598
+ self.reset_interrupt()
599
+ self.prompt_mode_flag = False
600
+
601
+ messages = self._to_messages(input_data)
602
+ tools = None
603
+ tool_mode = None
604
+ llm_supports_native_tools = False
605
+
606
+ if self._use_mcpp and self._tool_manager:
607
+ tools = None
608
+ if isinstance(self._llm, ClaudeAsyncLLM):
609
+ tool_mode = "Claude"
610
+ tools = self._formatted_tools_claude
611
+ llm_supports_native_tools = True
612
+ elif isinstance(self._llm, OpenAICompatibleAsyncLLM):
613
+ tool_mode = "OpenAI"
614
+ tools = self._formatted_tools_openai
615
+ llm_supports_native_tools = True
616
+ else:
617
+ logger.warning(
618
+ f"LLM type {type(self._llm)} not explicitly handled for tool mode determination."
619
+ )
620
+
621
+ if llm_supports_native_tools and not tools:
622
+ logger.warning(
623
+ f"No tools available/formatted for '{tool_mode}' mode, despite MCP being enabled."
624
+ )
625
+
626
+ if self._use_mcpp and tool_mode == "Claude":
627
+ logger.debug(
628
+ f"Starting Claude tool interaction loop with {len(tools)} tools."
629
+ )
630
+ async for output in self._claude_tool_interaction_loop(
631
+ messages, tools if tools else []
632
+ ):
633
+ yield output
634
+ return
635
+ elif self._use_mcpp and tool_mode == "OpenAI":
636
+ logger.debug(
637
+ f"Starting OpenAI tool interaction loop with {len(tools)} tools."
638
+ )
639
+ async for output in self._openai_tool_interaction_loop(
640
+ messages, tools if tools else []
641
+ ):
642
+ yield output
643
+ return
644
+ else:
645
+ logger.info("Starting simple chat completion.")
646
+ token_stream = self._llm.chat_completion(messages, self._system)
647
+ complete_response = ""
648
+ async for event in token_stream:
649
+ text_chunk = ""
650
+ if isinstance(event, dict) and event.get("type") == "text_delta":
651
+ text_chunk = event.get("text", "")
652
+ elif isinstance(event, str):
653
+ text_chunk = event
654
+ else:
655
+ continue
656
+ if text_chunk:
657
+ yield text_chunk
658
+ complete_response += text_chunk
659
+ if complete_response:
660
+ self._add_message(complete_response, "assistant")
661
+
662
+ return chat_with_memory
663
+
664
+ async def chat(
665
+ self,
666
+ input_data: BatchInput,
667
+ ) -> AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]:
668
+ """Run chat pipeline."""
669
+ chat_func_decorated = self._chat_function_factory()
670
+ async for output in chat_func_decorated(input_data):
671
+ yield output
672
+
673
+ def reset_interrupt(self) -> None:
674
+ """Reset interrupt flag."""
675
+ self._interrupt_handled = False
676
+
677
+ def start_group_conversation(
678
+ self, human_name: str, ai_participants: List[str]
679
+ ) -> None:
680
+ """Start a group conversation."""
681
+ if not self._tool_prompts:
682
+ logger.warning("Tool prompts dictionary is not set.")
683
+ return
684
+
685
+ other_ais = ", ".join(name for name in ai_participants)
686
+ prompt_name = self._tool_prompts.get("group_conversation_prompt", "")
687
+
688
+ if not prompt_name:
689
+ logger.warning("No group conversation prompt name found.")
690
+ return
691
+
692
+ try:
693
+ group_context = prompt_loader.load_util(prompt_name).format(
694
+ human_name=human_name, other_ais=other_ais
695
+ )
696
+ self._memory.append({"role": "user", "content": group_context})
697
+ except FileNotFoundError:
698
+ logger.error(f"Group conversation prompt file not found: {prompt_name}")
699
+ except KeyError as e:
700
+ logger.error(f"Missing formatting key in group conversation prompt: {e}")
701
+ except Exception as e:
702
+ logger.error(f"Failed to load group conversation prompt: {e}")
src/open_llm_vtuber/agent/agents/hume_ai.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ from typing import AsyncIterator, Optional
4
+ import json
5
+ import websockets
6
+ from loguru import logger
7
+ from pathlib import Path
8
+
9
+ from .agent_interface import AgentInterface
10
+ from ..output_types import AudioOutput, Actions, DisplayText
11
+ from ..input_types import BatchInput
12
+ from ...chat_history_manager import get_metadata, update_metadate
13
+
14
+
15
+ class HumeAIAgent(AgentInterface):
16
+ """
17
+ Hume AI Agent that handles text input and audio output.
18
+ Uses AudioOutput type to provide audio responses with transcripts.
19
+ """
20
+
21
+ AGENT_TYPE = "hume_ai_agent"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str,
26
+ host: str = "api.hume.ai",
27
+ config_id: Optional[str] = None,
28
+ idle_timeout: int = 15,
29
+ ):
30
+ """
31
+ Initialize Hume AI agent
32
+
33
+ Args:
34
+ api_key: Hume AI API key
35
+ host: API host
36
+ config_id: Optional configuration ID
37
+ idle_timeout: Connection idle timeout in seconds
38
+ """
39
+ self.api_key = api_key
40
+ self.host = host
41
+ self.config_id = config_id
42
+ self.idle_timeout = idle_timeout
43
+ self._ws = None
44
+ self._current_text = None
45
+ self._current_id = None
46
+ self._connected = False
47
+ self._chat_group_id = None
48
+ self._idle_timer = None
49
+ self._current_conf_uid = None
50
+ self._current_history_uid = None
51
+
52
+ # Create cache directory if it doesn't exist
53
+ self.cache_dir = Path("./cache")
54
+ self.cache_dir.mkdir(exist_ok=True)
55
+
56
+ async def connect(self, resume_chat_group_id: Optional[str] = None):
57
+ """
58
+ Establish WebSocket connection with optional chat group resumption
59
+
60
+ Args:
61
+ resume_chat_group_id: Optional chat group ID to resume
62
+ """
63
+ if self._ws:
64
+ await self._ws.close()
65
+ self._ws = None
66
+ self._connected = False
67
+
68
+ # Build URL with query parameters
69
+ socket_url = f"wss://{self.host}/v0/evi/chat?api_key={self.api_key}"
70
+
71
+ if self.config_id:
72
+ socket_url += f"&config_id={self.config_id}"
73
+
74
+ if resume_chat_group_id:
75
+ logger.info(f"Resuming chat group: {resume_chat_group_id}")
76
+ socket_url += f"&resumed_chat_group_id={resume_chat_group_id}"
77
+ self._chat_group_id = resume_chat_group_id
78
+
79
+ logger.info(f"Connecting to EVI with config_id: {self.config_id}")
80
+
81
+ self._ws = await websockets.connect(socket_url)
82
+ self._connected = True
83
+
84
+ async for message in self._ws:
85
+ data = json.loads(message)
86
+ if data.get("type") == "chat_metadata":
87
+ new_chat_group_id = data.get("chat_group_id")
88
+
89
+ if not resume_chat_group_id and self._current_history_uid:
90
+ update_metadate(
91
+ self._current_conf_uid,
92
+ self._current_history_uid,
93
+ {"resume_id": new_chat_group_id, "agent_type": self.AGENT_TYPE},
94
+ )
95
+
96
+ self._chat_group_id = new_chat_group_id
97
+ logger.info(
98
+ f"{'Resumed' if resume_chat_group_id else 'Created new'} "
99
+ f"chat group: {self._chat_group_id}"
100
+ )
101
+ break
102
+
103
+ def _reset_idle_timer(self):
104
+ """Reset the idle timer"""
105
+ if self._idle_timer:
106
+ self._idle_timer.cancel()
107
+
108
+ async def disconnect_after_timeout():
109
+ await asyncio.sleep(self.idle_timeout)
110
+ if self._ws and self._connected:
111
+ logger.info("Idle timeout reached, disconnecting...")
112
+ await self._ws.close()
113
+ self._connected = False
114
+
115
+ self._idle_timer = asyncio.create_task(disconnect_after_timeout())
116
+
117
+ async def _ensure_connection(self):
118
+ """Ensure connection is alive, reconnect if needed"""
119
+ if not self._connected or not self._ws or self._ws.closed:
120
+ await self.connect(self._chat_group_id)
121
+
122
+ def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
123
+ """
124
+ Set chat group ID based on history
125
+
126
+ Args:
127
+ conf_uid: Configuration ID
128
+ history_uid: History ID
129
+ """
130
+ self._current_conf_uid = conf_uid
131
+ self._current_history_uid = history_uid
132
+
133
+ metadata = get_metadata(conf_uid, history_uid)
134
+
135
+ agent_type = metadata.get("agent_type")
136
+ if agent_type and agent_type != self.AGENT_TYPE:
137
+ logger.warning(
138
+ f"Incompatible agent type in history: {agent_type}. "
139
+ f"Expected: {self.AGENT_TYPE} or empty. Memory will not be set."
140
+ )
141
+ self._chat_group_id = None
142
+ return
143
+
144
+ resume_id = metadata.get("resume_id")
145
+ if resume_id:
146
+ self._chat_group_id = resume_id
147
+ logger.info(f"Using resume_id from metadata: {resume_id}")
148
+ else:
149
+ self._chat_group_id = None
150
+ logger.info("No resume_id found in metadata, will create new chat group")
151
+
152
+ # Force reconnection on next chat
153
+ if self._ws:
154
+ asyncio.create_task(self._ws.close())
155
+ self._connected = False
156
+
157
+ async def chat(self, batch_input: BatchInput) -> AsyncIterator[AudioOutput]:
158
+ """
159
+ Chat with Hume AI and get audio response
160
+
161
+ Args:
162
+ batch_input: BatchInput containing text and optional media
163
+
164
+ Returns:
165
+ AsyncIterator[AudioOutput]: Stream of AudioOutput objects
166
+ """
167
+ try:
168
+ self._reset_idle_timer()
169
+ await self._ensure_connection()
170
+
171
+ # Extract main text from BatchInput
172
+ input_text = batch_input.texts[0].content if batch_input.texts else ""
173
+
174
+ # Hume AI doesn't support image input, log warning if images present
175
+ if batch_input.images:
176
+ logger.warning(
177
+ "Hume AI does not support image input. Images will be ignored."
178
+ )
179
+
180
+ message = {
181
+ "type": "user_input",
182
+ "text": input_text,
183
+ }
184
+ await self._ws.send(json.dumps(message))
185
+
186
+ async for message in self._ws:
187
+ self._reset_idle_timer()
188
+ logger.debug(f"Received message: {message}")
189
+ try:
190
+ response_data = json.loads(message)
191
+ msg_type = response_data.get("type")
192
+ msg_id = response_data.get("id")
193
+
194
+ if msg_type == "assistant_message":
195
+ self._current_text = response_data["message"]["content"]
196
+ self._current_id = msg_id
197
+
198
+ elif msg_type == "audio_output":
199
+ if msg_id == self._current_id and self._current_text:
200
+ audio_data = base64.b64decode(response_data["data"])
201
+ cache_file = self.cache_dir / f"evi_audio_{msg_id}.wav"
202
+
203
+ with open(cache_file, "wb") as f:
204
+ f.write(audio_data)
205
+ logger.debug(f"Saved audio to cache file: {cache_file}")
206
+
207
+ # Create AudioOutput with DisplayText
208
+ yield AudioOutput(
209
+ audio_path=str(cache_file),
210
+ display_text=DisplayText(text=self._current_text),
211
+ transcript=self._current_text,
212
+ actions=Actions(),
213
+ )
214
+
215
+ self._current_text = None
216
+ self._current_id = None
217
+
218
+ elif msg_type == "assistant_end":
219
+ break
220
+
221
+ elif msg_type == "tool_error_message":
222
+ logger.error(f"Tool error: {response_data.get('error')}")
223
+
224
+ except json.JSONDecodeError as e:
225
+ logger.error(f"Failed to parse response JSON: {e}")
226
+ continue
227
+
228
+ except websockets.exceptions.ConnectionClosed as e:
229
+ logger.warning(f"Connection closed: {e}, attempting to reconnect...")
230
+ self._connected = False
231
+ await self._ensure_connection()
232
+ async for result in self.chat(batch_input):
233
+ yield result
234
+
235
+ except Exception as e:
236
+ logger.error(f"Error in chat: {e}")
237
+ raise
238
+
239
+ def handle_interrupt(self, heard_response: str) -> None:
240
+ """Handle user interruption (not implemented for Hume AI)"""
241
+ pass
242
+
243
+ def __del__(self):
244
+ """Cleanup WebSocket connection and cache files"""
245
+ if self._idle_timer:
246
+ self._idle_timer.cancel()
247
+
248
+ if self._ws:
249
+ self._ws.close()
250
+
251
+ # Clean up cache files
252
+ try:
253
+ for file in self.cache_dir.glob("evi_audio_*.wav"):
254
+ file.unlink()
255
+ except Exception as e:
256
+ logger.error(f"Error cleaning up cache files: {e}")
src/open_llm_vtuber/agent/agents/letta_agent.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import AsyncIterator, List, Dict, Any
2
+ from .agent_interface import AgentInterface
3
+ from ..output_types import SentenceOutput
4
+ from ..transformers import (
5
+ sentence_divider,
6
+ actions_extractor,
7
+ tts_filter,
8
+ display_processor,
9
+ )
10
+ from ...config_manager import TTSPreprocessorConfig
11
+ from ..input_types import BatchInput, TextSource
12
+ from letta_client import Letta
13
+
14
+
15
+ class LettaAgent(AgentInterface):
16
+ """
17
+ Custom Letta class to interface with the Letta server.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ live2d_model,
23
+ id,
24
+ tts_preprocessor_config: TTSPreprocessorConfig = None,
25
+ faster_first_response: bool = True,
26
+ segment_method: str = "pysbd",
27
+ host: str = "localhost",
28
+ port: int = 8283,
29
+ ):
30
+ super().__init__()
31
+ self.url = f"http://{host}:{port}"
32
+ self.client = Letta(base_url=self.url)
33
+ self.id = id
34
+ # Initialize decorator parameters
35
+ self._tts_preprocessor_config = tts_preprocessor_config
36
+ self._live2d_model = live2d_model
37
+ self._faster_first_response = faster_first_response
38
+ self._segment_method = segment_method
39
+
40
+ # Delay decorator application
41
+ self.chat = tts_filter(self._tts_preprocessor_config)(
42
+ display_processor()(
43
+ actions_extractor(self._live2d_model)(
44
+ sentence_divider(
45
+ faster_first_response=self._faster_first_response,
46
+ segment_method=self._segment_method,
47
+ valid_tags=["think"],
48
+ )(self.chat)
49
+ )
50
+ )
51
+ )
52
+
53
+ def set_memory_from_history(self, conf_uid: str, history_uid: str) -> None:
54
+ # The Letta Server automatically stores historical messages, so this part is not needed
55
+ pass
56
+
57
+ def handle_interrupt(self, heard_response: str) -> None:
58
+ pass
59
+
60
+ async def generator_to_async(self, gen):
61
+ for item in gen:
62
+ yield item
63
+
64
+ async def chat(self, input_data: BatchInput) -> AsyncIterator[SentenceOutput]:
65
+ messages = self._to_messages(input_data)
66
+ stream = self.generator_to_async(
67
+ self.client.agents.messages.create_stream(
68
+ agent_id=self.id,
69
+ messages=messages,
70
+ stream_tokens=True,
71
+ )
72
+ )
73
+
74
+ complete_response = ""
75
+ async for token in stream:
76
+ if token.message_type == "reasoning_message":
77
+ # This part is reasoning information and should not be displayed
78
+ token = token.reasoning
79
+ continue
80
+ elif token.message_type == "assistant_message":
81
+ # This part is the result that needs to be displayed, it is the final result
82
+ # logger.info('Test message')
83
+ # logger.info(token)
84
+ token = token.content
85
+ else:
86
+ continue
87
+
88
+ yield token
89
+ complete_response += token
90
+
91
+ def _to_text_prompt(self, input_data: BatchInput) -> str:
92
+ """
93
+ Format BatchInput into a prompt string for the LLM.
94
+
95
+ Args:
96
+ input_data: BatchInput - The input data containing texts
97
+
98
+ Returns:
99
+ str - Formatted message string
100
+ """
101
+ message_parts = []
102
+
103
+ # Process text inputs in order
104
+ for text_data in input_data.texts:
105
+ if text_data.source == TextSource.INPUT:
106
+ message_parts.append(text_data.content)
107
+ elif text_data.source == TextSource.CLIPBOARD:
108
+ message_parts.append(f"[Clipboard content: {text_data.content}]")
109
+
110
+ return "\n".join(message_parts)
111
+
112
+ def _to_messages(self, input_data: BatchInput) -> List[Dict[str, Any]]:
113
+ """
114
+ Prepare messages list without image support.
115
+ """
116
+ messages = []
117
+
118
+ if input_data.images:
119
+ content = []
120
+ text_content = self._to_text_prompt(input_data)
121
+ content.append({"type": "text", "text": text_content})
122
+ user_message = {"role": "user", "content": content}
123
+ else:
124
+ user_message = {"role": "user", "content": self._to_text_prompt(input_data)}
125
+
126
+ messages.append(user_message)
127
+
128
+ return messages
src/open_llm_vtuber/agent/agents/mem0_llm.py ADDED
File without changes
src/open_llm_vtuber/agent/input_types.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import List, Optional, Dict, Any
4
+
5
+
6
+ class ImageSource(Enum):
7
+ """Enum for different image sources"""
8
+
9
+ CAMERA = "camera"
10
+ SCREEN = "screen"
11
+ CLIPBOARD = "clipboard"
12
+ UPLOAD = "upload"
13
+
14
+
15
+ class TextSource(Enum):
16
+ """Enum for different text sources"""
17
+
18
+ INPUT = "input" # Main user input/transcription
19
+ CLIPBOARD = "clipboard" # Text from clipboard
20
+
21
+
22
+ @dataclass
23
+ class ImageData:
24
+ """
25
+ Represents an image from various sources
26
+
27
+ Attributes:
28
+ source: Source of the image
29
+ data: Base64 encoded image data or URL
30
+ mime_type: MIME type of the image (e.g., 'image/jpeg', 'image/png')
31
+ """
32
+
33
+ source: ImageSource
34
+ data: str # Base64 encoded or URL
35
+ mime_type: str
36
+
37
+
38
+ @dataclass
39
+ class FileData:
40
+ """
41
+ Represents a file uploaded by the user
42
+
43
+ Attributes:
44
+ name: Original filename
45
+ data: Base64 encoded file data
46
+ mime_type: MIME type of the file
47
+ """
48
+
49
+ name: str
50
+ data: str # Base64 encoded
51
+ mime_type: str
52
+
53
+
54
+ @dataclass
55
+ class TextData:
56
+ """
57
+ Represents text data from various sources
58
+
59
+ Attributes:
60
+ source: Source of the text
61
+ content: str - The text content
62
+ from_name: Optional[str] - Name of the sender/character
63
+ """
64
+
65
+ source: TextSource
66
+ content: str
67
+ from_name: Optional[str] = None
68
+
69
+
70
+ class BaseInput:
71
+ """Base class for all input types"""
72
+
73
+ pass
74
+
75
+
76
+ @dataclass
77
+ class BatchInput(BaseInput):
78
+ """
79
+ Input type for batch processing, containing complete transcription and optional media
80
+
81
+ Attributes:
82
+ texts: List of text data from different sources
83
+ images: Optional list of images
84
+ files: Optional list of files
85
+ metadata: Optional dictionary of metadata flags for special inputs
86
+ - 'proactive_speak': Boolean flag indicating if this is a proactive speak input
87
+ - 'skip_memory': Boolean flag indicating if this input should be skipped in AI's internal memory
88
+ - 'skip_history': Boolean flag indicating if this input should be skipped in local history storage
89
+ """
90
+
91
+ texts: List[TextData]
92
+ images: Optional[List[ImageData]] = None
93
+ files: Optional[List[FileData]] = None
94
+ metadata: Optional[Dict[str, Any]] = None
src/open_llm_vtuber/agent/output_types.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, asdict
2
+ from typing import List, Optional
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ @dataclass
7
+ class Actions:
8
+ """Represents actions that can be performed alongside text output"""
9
+
10
+ expressions: Optional[List[str] | List[int]] = None
11
+ pictures: Optional[List[str]] = None
12
+ sounds: Optional[List[str]] = None
13
+
14
+ def to_dict(self) -> dict:
15
+ """Convert Actions object to a dictionary for JSON serialization"""
16
+ return {k: v for k, v in asdict(self).items() if v is not None}
17
+
18
+
19
+ class BaseOutput(ABC):
20
+ """Base class for agent outputs that can be iterated"""
21
+
22
+ @abstractmethod
23
+ def __aiter__(self):
24
+ """Make the output iterable"""
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class DisplayText:
30
+ """Text to be displayed with optional metadata"""
31
+
32
+ text: str
33
+ name: Optional[str] = "AI" # Keep the name field for frontend display
34
+ avatar: Optional[str] = None
35
+
36
+ def to_dict(self) -> dict:
37
+ """Convert to dictionary for JSON serialization"""
38
+ return {"text": self.text, "name": self.name, "avatar": self.avatar}
39
+
40
+ def __str__(self) -> str:
41
+ """String representation for logging"""
42
+ return f"{self.name}: {self.text}"
43
+
44
+
45
+ @dataclass
46
+ class SentenceOutput(BaseOutput):
47
+ """
48
+ Output type for text-based responses.
49
+ Contains a single sentence pair (display and TTS) with associated actions.
50
+
51
+ Attributes:
52
+ display_text: Text to be displayed in UI
53
+ tts_text: Text to be sent to TTS engine
54
+ actions: Associated actions (expressions, pictures, sounds)
55
+ """
56
+
57
+ display_text: DisplayText # Changed from str to DisplayText
58
+ tts_text: str # Text for TTS
59
+ actions: Actions
60
+
61
+ async def __aiter__(self):
62
+ """Yield the sentence pair and actions"""
63
+ yield self.display_text, self.tts_text, self.actions
64
+
65
+
66
+ @dataclass
67
+ class AudioOutput(BaseOutput):
68
+ """Output type for audio-based responses"""
69
+
70
+ audio_path: str
71
+ display_text: DisplayText # Changed from str to DisplayText
72
+ transcript: str # Original transcript
73
+ actions: Actions
74
+
75
+ async def __aiter__(self):
76
+ """Iterate through audio segments and their actions"""
77
+ yield self.audio_path, self.display_text, self.transcript, self.actions
src/open_llm_vtuber/agent/stateless_llm/__init__.py ADDED
File without changes
src/open_llm_vtuber/agent/stateless_llm/claude_llm.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Description: This file contains the implementation of the `AsyncLLM` class for Claude API.
2
+ This class is responsible for handling asynchronous interaction with Claude API endpoints
3
+ for language generation.
4
+ """
5
+
6
+ import json
7
+ from typing import AsyncIterator, List, Dict, Any
8
+
9
+ from loguru import logger
10
+ from anthropic import AsyncAnthropic, NOT_GIVEN
11
+
12
+ from .stateless_llm_interface import StatelessLLMInterface
13
+
14
+
15
+ class AsyncLLM(StatelessLLMInterface):
16
+ def __init__(
17
+ self,
18
+ model: str = "claude-3-haiku-latest",
19
+ base_url: str = None,
20
+ llm_api_key: str = None,
21
+ system: str = None,
22
+ ):
23
+ """
24
+ Initialize Claude LLM.
25
+
26
+ Args:
27
+ model (str): Model name
28
+ base_url (str): Base URL for Claude API
29
+ llm_api_key (str): Claude API key
30
+ system (str): System prompt
31
+ """
32
+ self.model = model
33
+ self.system = system
34
+
35
+ # Initialize Claude client
36
+ self.client = AsyncAnthropic(
37
+ api_key=llm_api_key, base_url=base_url if base_url else None
38
+ )
39
+
40
+ logger.info(f"Initialized Claude AsyncLLM with model: {self.model}")
41
+ logger.debug(f"Base URL: {base_url}")
42
+
43
+ def _convert_message_format(self, message: Dict[str, Any]) -> Dict[str, Any]:
44
+ """Convert message format to Claude's expected format."""
45
+ # Handle potential tool_result content blocks
46
+ if isinstance(message.get("content"), list):
47
+ new_content = []
48
+ is_tool_result = False
49
+ for content_item in message["content"]:
50
+ if content_item.get("type") == "image_url":
51
+ # Extract media type and base64 data from data URL
52
+ data_url = content_item["image_url"]["url"]
53
+ # Split 'data:image/jpeg;base64,/9j/4AAQ...' into parts
54
+ header, base64_data = data_url.split(",", 1)
55
+ # Extract media type from 'data:image/jpeg;base64'
56
+ media_type = header.split(":")[1].split(";")[0]
57
+
58
+ new_content.append(
59
+ {
60
+ "type": "image",
61
+ "source": {
62
+ "type": "base64",
63
+ "media_type": media_type,
64
+ "data": base64_data,
65
+ },
66
+ }
67
+ )
68
+ elif content_item.get("type") == "tool_result":
69
+ is_tool_result = True
70
+ # Keep tool_result block as is, Anthropic SDK handles it
71
+ new_content.append(content_item)
72
+ else:
73
+ # Assume text or other standard types
74
+ new_content.append(content_item)
75
+
76
+ # For tool_result messages, the role should be 'user'
77
+ # Ensure the role is correctly set before returning
78
+ role = "user" if is_tool_result else message["role"]
79
+ return {"role": role, "content": new_content}
80
+
81
+ # Handle plain text content or non-list content
82
+ return message
83
+
84
+ async def chat_completion(
85
+ self,
86
+ messages: List[Dict[str, Any]],
87
+ system: str = None,
88
+ tools: List[Dict[str, Any]] = None,
89
+ ) -> AsyncIterator[Dict[str, Any]]:
90
+ """
91
+ Generates a chat completion using the Claude API asynchronously,
92
+ handling text generation and tool use.
93
+
94
+ Parameters:
95
+ - messages (List[Dict[str, Any]]): The list of messages to send to the API.
96
+ - system (str, optional): System prompt to use for this completion.
97
+ - tools (List[Dict[str, Any]], optional): List of tools available.
98
+
99
+ Yields:
100
+ - Dict[str, Any]: Events representing text deltas, tool use, or errors.
101
+ Possible event types:
102
+ - {"type": "message_start", "data": ...}
103
+ - {"type": "text_delta", "text": "..."}
104
+ - {"type": "tool_use_start", "data": {"id": ..., "name": ..., "input": None}}
105
+ - {"type": "tool_input_delta", "tool_id": ..., "partial_json": "..."} # Optional
106
+ - {"type": "tool_use_complete", "data": {"id": ..., "name": ..., "input": {...}}}
107
+ - {"type": "message_delta", "data": ...} # e.g., stop_reason
108
+ - {"type": "message_stop"}
109
+ - {"type": "error", "message": "..."}
110
+ """
111
+ try:
112
+ # Filter out system messages and convert message format
113
+ converted_messages = [
114
+ self._convert_message_format(msg)
115
+ for msg in messages
116
+ if msg["role"] != "system"
117
+ ]
118
+
119
+ logger.debug(f"Sending messages to Claude API: {converted_messages}")
120
+ logger.debug(f"Tools provided: {tools}")
121
+
122
+ async with self.client.messages.stream(
123
+ messages=converted_messages,
124
+ system=system if system else (self.system if self.system else ""),
125
+ model=self.model,
126
+ max_tokens=1024,
127
+ tools=tools if tools else NOT_GIVEN,
128
+ ) as stream:
129
+ current_tool_call_info = None
130
+ partial_json_accumulator = ""
131
+
132
+ async for event in stream:
133
+ if event.type == "message_start":
134
+ logger.debug("Stream: message_start")
135
+ yield {
136
+ "type": "message_start",
137
+ "data": event.message.model_dump(exclude_none=True),
138
+ }
139
+ elif event.type == "content_block_start":
140
+ logger.debug(
141
+ f"Stream: content_block_start - Index: {event.index}, Type: {event.content_block.type}"
142
+ )
143
+ if event.content_block.type == "text":
144
+ pass # Handled by text_delta
145
+ elif event.content_block.type == "tool_use":
146
+ current_tool_call_info = {
147
+ "id": event.content_block.id,
148
+ "name": event.content_block.name,
149
+ "input": None,
150
+ "index": event.index, # Store index
151
+ }
152
+ partial_json_accumulator = ""
153
+ logger.debug(
154
+ f"Stream: tool_use started - ID: {current_tool_call_info['id']}, Name: {current_tool_call_info['name']}"
155
+ )
156
+ yield {
157
+ "type": "tool_use_start",
158
+ "data": current_tool_call_info.copy(),
159
+ }
160
+ elif event.type == "content_block_delta":
161
+ logger.debug(
162
+ f"Stream: content_block_delta - Index: {event.index}, Delta Type: {event.delta.type}"
163
+ )
164
+ if event.delta.type == "text_delta":
165
+ yield {"type": "text_delta", "text": event.delta.text}
166
+ elif event.delta.type == "input_json_delta":
167
+ if (
168
+ current_tool_call_info
169
+ and event.index == current_tool_call_info["index"]
170
+ ):
171
+ partial_json_accumulator += event.delta.partial_json
172
+ logger.trace(
173
+ f"Stream: input_json_delta - Tool ID: {current_tool_call_info['id']}, Partial: {event.delta.partial_json}"
174
+ )
175
+ else:
176
+ logger.warning(
177
+ f"Received input_json_delta but no active tool call matching index {event.index}"
178
+ )
179
+ elif event.type == "content_block_stop":
180
+ logger.debug(
181
+ f"Stream: content_block_stop - Index: {event.index}"
182
+ )
183
+ # Check if this stop corresponds to the active tool call
184
+ if (
185
+ current_tool_call_info
186
+ and event.index == current_tool_call_info["index"]
187
+ ):
188
+ try:
189
+ if not partial_json_accumulator.strip():
190
+ logger.warning(
191
+ f"Empty JSON input received for tool ID: {current_tool_call_info['id']}. Using empty object."
192
+ )
193
+ tool_input = {}
194
+ else:
195
+ tool_input = json.loads(partial_json_accumulator)
196
+ current_tool_call_info["input"] = tool_input
197
+ logger.debug(
198
+ f"Stream: tool_use completed - ID: {current_tool_call_info['id']}, Input: {tool_input}"
199
+ )
200
+ # Yield the complete tool call info
201
+ yield {
202
+ "type": "tool_use_complete",
203
+ "data": current_tool_call_info.copy(),
204
+ }
205
+ except json.JSONDecodeError as e:
206
+ logger.error(
207
+ f"Failed to decode tool input JSON: {partial_json_accumulator}. Error: {e}"
208
+ )
209
+ yield {
210
+ "type": "error",
211
+ "message": f"Failed to parse tool input JSON for tool ID {current_tool_call_info['id']}",
212
+ }
213
+ finally:
214
+ # Reset regardless of success or failure for this index
215
+ current_tool_call_info = None
216
+ partial_json_accumulator = ""
217
+ elif event.type == "message_delta":
218
+ logger.debug(
219
+ f"Stream: message_delta - Delta: {event.delta.model_dump(exclude_none=True)}, Usage: {event.usage}"
220
+ )
221
+ yield {
222
+ "type": "message_delta",
223
+ "data": {
224
+ "delta": event.delta.model_dump(exclude_none=True),
225
+ "usage": event.usage.model_dump(),
226
+ },
227
+ }
228
+ elif event.type == "message_stop":
229
+ logger.debug("Stream: message_stop")
230
+ yield {"type": "message_stop"}
231
+ # No need to break here, the context manager handles the end
232
+ elif event.type == "ping":
233
+ logger.trace("Stream: ping")
234
+ pass # Ignore pings
235
+ # Anthropic SDK might raise errors directly, or via event.type == 'error'
236
+ # The outer try/except handles SDK-level errors.
237
+
238
+ except Exception as e:
239
+ logger.error(f"Claude API error occurred: {str(e)}")
240
+ logger.info(f"Model: {self.model}")
241
+ # Yield an error event before raising
242
+ yield {"type": "error", "message": f"Claude API error: {str(e)}"}
243
+ raise
244
+
245
+ # No finally block needed for stream.close() due to async with
246
+ logger.debug("Chat completion stream processing finished.")
src/open_llm_vtuber/agent/stateless_llm/llama_cpp_llm.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Description: This file contains the implementation of the LLM class using llama.cpp.
2
+ This class provides a stateless interface to llama.cpp for language generation.
3
+ """
4
+
5
+ import asyncio
6
+ from typing import AsyncIterator, List, Dict, Any
7
+ from llama_cpp import Llama
8
+ from loguru import logger
9
+
10
+ from .stateless_llm_interface import StatelessLLMInterface
11
+
12
+
13
+ class LLM(StatelessLLMInterface):
14
+ def __init__(
15
+ self,
16
+ model_path: str,
17
+ **kwargs,
18
+ ):
19
+ """
20
+ Initializes a stateless instance of the LLM class using llama.cpp.
21
+
22
+ Parameters:
23
+ - model_path (str): Path to the GGUF model file
24
+ - **kwargs: Additional arguments passed to Llama constructor
25
+ """
26
+ logger.info(f"Initializing llama cpp with model path: {model_path}")
27
+ self.model_path = model_path
28
+ try:
29
+ self.llm = Llama(model_path=model_path, **kwargs)
30
+ except Exception as e:
31
+ logger.critical(f"Failed to initialize Llama model: {e}")
32
+ raise
33
+
34
+ async def chat_completion(
35
+ self, messages: List[Dict[str, Any]], system: str = None
36
+ ) -> AsyncIterator[str]:
37
+ """
38
+ Generates a chat completion using llama.cpp asynchronously.
39
+
40
+ Parameters:
41
+ - messages (List[Dict[str, Any]]): The list of messages to send to the model.
42
+ - system (str, optional): System prompt to use for this completion.
43
+
44
+ Yields:
45
+ - str: The content of each chunk from the model response.
46
+ """
47
+ logger.debug(f"Generating completion for messages: {messages}")
48
+
49
+ try:
50
+ # Add system prompt if provided
51
+ messages_with_system = messages
52
+ if system:
53
+ messages_with_system = [
54
+ {"role": "system", "content": system},
55
+ *messages,
56
+ ]
57
+
58
+ # Create chat completion in a separate thread to avoid blocking
59
+ chat_completion = await asyncio.get_event_loop().run_in_executor(
60
+ None,
61
+ lambda: self.llm.create_chat_completion(
62
+ messages=messages_with_system,
63
+ stream=True,
64
+ ),
65
+ )
66
+
67
+ # Process chunks
68
+ for chunk in chat_completion:
69
+ if chunk.get("choices") and chunk["choices"][0].get("delta"):
70
+ content = chunk["choices"][0]["delta"].get("content", "")
71
+ if content:
72
+ yield content
73
+
74
+ except Exception as e:
75
+ logger.error(f"Error in chat completion: {e}")
76
+ raise
src/open_llm_vtuber/agent/stateless_llm/ollama_llm.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import atexit
2
+ import requests
3
+ from loguru import logger
4
+ from .openai_compatible_llm import AsyncLLM
5
+
6
+
7
+ class OllamaLLM(AsyncLLM):
8
+ def __init__(
9
+ self,
10
+ model: str,
11
+ base_url: str,
12
+ llm_api_key: str = "z",
13
+ organization_id: str = "z",
14
+ project_id: str = "z",
15
+ temperature: float = 1.0,
16
+ keep_alive: float = -1,
17
+ unload_at_exit: bool = True,
18
+ ):
19
+ self.keep_alive = keep_alive
20
+ self.unload_at_exit = unload_at_exit
21
+ self.cleaned = False
22
+ super().__init__(
23
+ model=model,
24
+ base_url=base_url,
25
+ llm_api_key=llm_api_key,
26
+ organization_id=organization_id,
27
+ project_id=project_id,
28
+ temperature=temperature,
29
+ )
30
+ try:
31
+ # preload model
32
+ logger.info("Preloading model for Ollama")
33
+ # Send the POST request to preload model
34
+ logger.debug(
35
+ requests.post(
36
+ base_url.replace("/v1", "") + "/api/chat",
37
+ json={
38
+ "model": model,
39
+ "keep_alive": keep_alive,
40
+ },
41
+ )
42
+ )
43
+ except requests.exceptions.ConnectionError as e:
44
+ logger.error(f"Failed to preload model: {e}")
45
+ logger.critical(
46
+ "Fail to connect to Ollama backend. Is Ollama server running? Try running `ollama list` to start the server and try again.\nThe AI will repeat 'Error connecting chat endpoint' until the server is running."
47
+ )
48
+ except Exception as e:
49
+ logger.error(f"Failed to preload model: {e}")
50
+ # If keep_alive is less than 0, register cleanup to unload the model
51
+ if unload_at_exit:
52
+ atexit.register(self.cleanup)
53
+
54
+ def __del__(self):
55
+ """Destructor to unload the model"""
56
+ self.cleanup()
57
+
58
+ def cleanup(self):
59
+ """Clean up function to unload the model when exitting"""
60
+ if not self.cleaned and self.unload_at_exit:
61
+ logger.info(f"Ollama: Unloading model: {self.model}")
62
+ # Unload the model
63
+ # unloading is just the same as preload, but with keep alive set to 0
64
+ logger.debug(
65
+ requests.post(
66
+ self.base_url.replace("/v1", "") + "/api/chat",
67
+ json={
68
+ "model": self.model,
69
+ "keep_alive": 0,
70
+ },
71
+ )
72
+ )
73
+ self.cleaned = True
src/open_llm_vtuber/agent/stateless_llm/openai_compatible_llm.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Description: This file contains the implementation of the `AsyncLLM` class.
2
+ This class is responsible for handling asynchronous interaction with OpenAI API compatible
3
+ endpoints for language generation.
4
+ """
5
+
6
+ from typing import AsyncIterator, List, Dict, Any
7
+ from openai import (
8
+ AsyncStream,
9
+ AsyncOpenAI,
10
+ APIError,
11
+ APIConnectionError,
12
+ RateLimitError,
13
+ NotGiven,
14
+ NOT_GIVEN,
15
+ )
16
+ from openai.types.chat import ChatCompletionChunk
17
+ from openai.types.chat.chat_completion_chunk import ChoiceDeltaToolCall
18
+ from loguru import logger
19
+
20
+ from .stateless_llm_interface import StatelessLLMInterface
21
+ from ...mcpp.types import ToolCallObject
22
+
23
+
24
+ class AsyncLLM(StatelessLLMInterface):
25
+ def __init__(
26
+ self,
27
+ model: str,
28
+ base_url: str,
29
+ llm_api_key: str = "z",
30
+ organization_id: str = "z",
31
+ project_id: str = "z",
32
+ temperature: float = 1.0,
33
+ ):
34
+ """
35
+ Initializes an instance of the `AsyncLLM` class.
36
+
37
+ Parameters:
38
+ - model (str): The model to be used for language generation.
39
+ - base_url (str): The base URL for the OpenAI API.
40
+ - organization_id (str, optional): The organization ID for the OpenAI API. Defaults to "z".
41
+ - project_id (str, optional): The project ID for the OpenAI API. Defaults to "z".
42
+ - llm_api_key (str, optional): The API key for the OpenAI API. Defaults to "z".
43
+ - temperature (float, optional): What sampling temperature to use, between 0 and 2. Defaults to 1.0.
44
+ """
45
+ self.base_url = base_url
46
+ self.model = model
47
+ self.temperature = temperature
48
+ self.client = AsyncOpenAI(
49
+ base_url=base_url,
50
+ organization=organization_id,
51
+ project=project_id,
52
+ api_key=llm_api_key,
53
+ )
54
+ self.support_tools = True
55
+
56
+ logger.info(
57
+ f"Initialized AsyncLLM with the parameters: {self.base_url}, {self.model}"
58
+ )
59
+
60
+ async def chat_iter(self, messages, system_prompt, tools=None):
61
+ """Đảm bảo hàm này tồn tại để Agent có thể gọi được"""
62
+ async for chunk in self.chat_completion(messages, system=system_prompt, tools=tools):
63
+ yield chunk
64
+
65
+ async def chat_completion(
66
+ self,
67
+ messages: List[Dict[str, Any]],
68
+ system: str = None,
69
+ tools: List[Dict[str, Any]] | NotGiven = NOT_GIVEN,
70
+ ) -> AsyncIterator[str | List[ChoiceDeltaToolCall]]:
71
+ """
72
+ Generates a chat completion using the OpenAI API asynchronously.
73
+
74
+ Parameters:
75
+ - messages (List[Dict[str, Any]]): The list of messages to send to the API.
76
+ - system (str, optional): System prompt to use for this completion.
77
+ - tools (List[Dict[str, str]], optional): List of tools to use for this completion.
78
+
79
+ Yields:
80
+ - str: The content of each chunk from the API response.
81
+ - List[ChoiceDeltaToolCall]: The tool calls detected in the response.
82
+
83
+ Raises:
84
+ - APIConnectionError: When the server cannot be reached
85
+ - RateLimitError: When a 429 status code is received
86
+ - APIError: For other API-related errors
87
+ """
88
+ stream = None
89
+ # Tool call related state variables
90
+ accumulated_tool_calls = {}
91
+ in_tool_call = False
92
+
93
+ try:
94
+ # If system prompt is provided, add it to the messages
95
+ messages_with_system = messages
96
+ if system:
97
+ messages_with_system = [
98
+ {"role": "system", "content": system},
99
+ *messages,
100
+ ]
101
+ logger.debug(f"Messages: {messages_with_system}")
102
+
103
+ available_tools = tools if self.support_tools else NOT_GIVEN
104
+
105
+ stream: AsyncStream[
106
+ ChatCompletionChunk
107
+ ] = await self.client.chat.completions.create(
108
+ messages=messages_with_system,
109
+ model=self.model,
110
+ stream=True,
111
+ temperature=self.temperature,
112
+ tools=available_tools,
113
+ )
114
+ logger.debug(
115
+ f"Tool Support: {self.support_tools}, Available tools: {available_tools}"
116
+ )
117
+
118
+ async for chunk in stream:
119
+ # Guard against chunks with missing choices field (e.g., from OpenWebUI)
120
+ if not chunk.choices:
121
+ continue
122
+
123
+ if self.support_tools:
124
+ has_tool_calls = (
125
+ hasattr(chunk.choices[0].delta, "tool_calls")
126
+ and chunk.choices[0].delta.tool_calls
127
+ )
128
+
129
+ if has_tool_calls:
130
+ logger.debug(
131
+ f"Tool calls detected in chunk: {chunk.choices[0].delta.tool_calls}"
132
+ )
133
+ in_tool_call = True
134
+ # Process tool calls in the current chunk
135
+ for tool_call in chunk.choices[0].delta.tool_calls:
136
+ index = (
137
+ tool_call.index if hasattr(tool_call, "index") else 0
138
+ )
139
+
140
+ # Initialize tool call for this index if needed
141
+ if index not in accumulated_tool_calls:
142
+ accumulated_tool_calls[index] = {
143
+ "index": index,
144
+ "id": getattr(tool_call, "id", None),
145
+ "type": getattr(tool_call, "type", None),
146
+ "function": {"name": "", "arguments": ""},
147
+ }
148
+
149
+ # Update tool call information
150
+ if hasattr(tool_call, "id") and tool_call.id:
151
+ accumulated_tool_calls[index]["id"] = tool_call.id
152
+ if hasattr(tool_call, "type") and tool_call.type:
153
+ accumulated_tool_calls[index]["type"] = tool_call.type
154
+
155
+ # Update function information
156
+ if hasattr(tool_call, "function"):
157
+ if (
158
+ hasattr(tool_call.function, "name")
159
+ and tool_call.function.name
160
+ ):
161
+ accumulated_tool_calls[index]["function"][
162
+ "name"
163
+ ] = tool_call.function.name
164
+ if (
165
+ hasattr(tool_call.function, "arguments")
166
+ and tool_call.function.arguments
167
+ ):
168
+ accumulated_tool_calls[index]["function"][
169
+ "arguments"
170
+ ] += tool_call.function.arguments
171
+
172
+ continue
173
+
174
+ # If we were in a tool call but now we're not, yield the tool call result
175
+ elif in_tool_call and not has_tool_calls:
176
+ in_tool_call = False
177
+ # Convert accumulated tool calls to the required format and output
178
+ logger.info(f"Complete tool calls: {accumulated_tool_calls}")
179
+
180
+ # Use the from_dict method to create a ToolCallObject instance from a dictionary
181
+ complete_tool_calls = [
182
+ ToolCallObject.from_dict(tool_data)
183
+ for tool_data in accumulated_tool_calls.values()
184
+ ]
185
+
186
+ yield complete_tool_calls
187
+ accumulated_tool_calls = {} # Reset for potential future tool calls
188
+
189
+ # Process regular content chunks
190
+ if len(chunk.choices) == 0:
191
+ logger.info("Empty chunk received")
192
+ continue
193
+ elif chunk.choices[0].delta.content is None:
194
+ chunk.choices[0].delta.content = ""
195
+ yield chunk.choices[0].delta.content
196
+
197
+ # If stream ends while still in a tool call, make sure to yield the tool call
198
+ if in_tool_call and accumulated_tool_calls:
199
+ logger.info(f"Final tool call at stream end: {accumulated_tool_calls}")
200
+
201
+ # Create a ToolCallObject instance from a dictionary using the from_dict method.
202
+ complete_tool_calls = [
203
+ ToolCallObject.from_dict(tool_data)
204
+ for tool_data in accumulated_tool_calls.values()
205
+ ]
206
+
207
+ yield complete_tool_calls
208
+
209
+ except APIConnectionError as e:
210
+ logger.error(
211
+ f"Error calling the chat endpoint: Connection error. Failed to connect to the LLM API. \nCheck the configurations and the reachability of the LLM backend. \nSee the logs for details. \nTroubleshooting with documentation: https://open-llm-vtuber.github.io/docs/faq#%E9%81%87%E5%88%B0-error-calling-the-chat-endpoint-%E9%94%99%E8%AF%AF%E6%80%8E%E4%B9%88%E5%8A%9E \n{e.__cause__}"
212
+ )
213
+ yield "Error calling the chat endpoint: Connection error. Failed to connect to the LLM API. Check the configurations and the reachability of the LLM backend. See the logs for details. Troubleshooting with documentation: [https://open-llm-vtuber.github.io/docs/faq#%E9%81%87%E5%88%B0-error-calling-the-chat-endpoint-%E9%94%99%E8%AF%AF%E6%80%8E%E4%B9%88%E5%8A%9E]"
214
+
215
+ except RateLimitError as e:
216
+ logger.error(
217
+ f"Error calling the chat endpoint: Rate limit exceeded: {e.response}"
218
+ )
219
+ yield "Error calling the chat endpoint: Rate limit exceeded. Please try again later. See the logs for details."
220
+
221
+ except APIError as e:
222
+ if "does not support tools" in str(e):
223
+ self.support_tools = False
224
+ logger.warning(
225
+ f"{self.model} does not support tools. Disabling tool support."
226
+ )
227
+ yield "__API_NOT_SUPPORT_TOOLS__"
228
+ return
229
+ logger.error(f"LLM API: Error occurred: {e}")
230
+ logger.info(f"Base URL: {self.base_url}")
231
+ logger.info(f"Model: {self.model}")
232
+ logger.info(f"Messages: {messages}")
233
+ logger.info(f"temperature: {self.temperature}")
234
+ yield "Error calling the chat endpoint: Error occurred while generating response. See the logs for details."
235
+
236
+ finally:
237
+ # make sure the stream is properly closed
238
+ # so when interrupted, no more tokens will being generated.
239
+ if stream:
240
+ logger.debug("Chat completion finished.")
241
+ await stream.close()
242
+ logger.debug("Stream closed.")
src/open_llm_vtuber/agent/stateless_llm/stateless_llm_interface.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import abc
2
+ from typing import AsyncIterator, List, Dict, Any
3
+
4
+
5
+ class StatelessLLMInterface(metaclass=abc.ABCMeta):
6
+ """
7
+ Interface for a stateless language model.
8
+
9
+ The word "stateless" means that the language model does not store memory,
10
+ system prompts, or user messages, which is most of the LLM. If we send a
11
+ message to the LLM, its response will be based on the message parameter alone.
12
+
13
+ The StatelessLLMInterface class provides a method for generating chat
14
+ completions asynchronously.
15
+
16
+ We use StatelessLLMs to initialize Agents, which pack the StatelessLLM with
17
+ memory, system prompts, and other features.
18
+
19
+ """
20
+
21
+ @abc.abstractmethod
22
+ async def chat_completion(
23
+ self,
24
+ messages: List[Dict[str, Any]],
25
+ system: str = None,
26
+ tools: List[Dict[str, Any]] = None,
27
+ ) -> AsyncIterator[str]:
28
+ """
29
+ Generates a chat completion asynchronously and return an iterator to the response.
30
+ This function does not store memory or user messages.
31
+
32
+ Parameters:
33
+ - messages (List[Dict[str, Any]]): The list of messages to send to the API.
34
+ - system (str, optional): System prompt to use for this completion.
35
+ - tools (List[Dict[str, str]], optional): List of tools to use for this completion.
36
+ - Each tool should follow the format:
37
+ {
38
+ "name": "tool_name",
39
+ "description": "tool_description",
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": {
43
+ "param1": {
44
+ "type": "string",
45
+ "description": "Description of param1"
46
+ },
47
+ "param2": {
48
+ "type": "integer",
49
+ "description": "Description of param2"
50
+ }
51
+ },
52
+ "required": ["param1"]
53
+ }
54
+ }
55
+
56
+ Yields:
57
+ - str: The content of each chunk from the API response.
58
+
59
+ Raises:
60
+ - APIConnectionError: When the server cannot be reached
61
+ - RateLimitError: When a 429 status code is received
62
+ - APIError: For other API-related errors
63
+ """
64
+ raise NotImplementedError
src/open_llm_vtuber/agent/stateless_llm/stateless_llm_with_template.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Description: This file contains the implementation of the `AsyncLLMTemplate` class.
2
+ This class is responsible for handling asynchronous interaction with OpenAI API
3
+ compatible endpoints for language generation where the language model is not
4
+ trained using a ChatML format.
5
+ """
6
+
7
+ import requests
8
+ import json
9
+ from jinja2 import Template
10
+ from loguru import logger
11
+ from typing import AsyncIterator, List, Dict, Any
12
+
13
+ from .stateless_llm_interface import StatelessLLMInterface
14
+
15
+
16
+ TEMPLATES = {
17
+ "LLAMA3": {
18
+ "template": "".join(
19
+ [
20
+ "{{ bos_token }}",
21
+ "{% for message in messages %}",
22
+ " {{ '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n'+ message['content'] | trim + '<|eot_id|>' }}",
23
+ "{% endfor %}",
24
+ "{% if add_generation_prompt %}",
25
+ " {{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}",
26
+ "{% endif %}",
27
+ ]
28
+ ),
29
+ "eot_token": "<|eot_id|>",
30
+ },
31
+ "CHATML": {
32
+ "template": "".join(
33
+ [
34
+ "{{ bos_token }}",
35
+ "{% for message in messages %}",
36
+ " {{ '<|im_start|>' + message['role'] + '\n' + message['content'] | trim + '<|im_end|>\n' }}",
37
+ "{% endfor %}",
38
+ "{% if add_generation_prompt %}",
39
+ " {{ '<|im_start|>assistant\n' }}",
40
+ "{% endif %}",
41
+ ]
42
+ ),
43
+ "eot_token": "<|im_end|>",
44
+ },
45
+ "ALPACA": {
46
+ "template": "".join(
47
+ [
48
+ """
49
+ {{ (messages|selectattr('role', 'equalto', 'system')|list|last).content|trim if (messages|selectattr('role', 'equalto', 'system')|list) else '' }}
50
+
51
+ {% for message in messages %}
52
+ {% if message['role'] == 'user' %}
53
+ ### Instruction:
54
+ {{ message['content']|trim -}}
55
+ {% if not loop.last %}
56
+
57
+
58
+ {% endif %}
59
+ {% elif message['role'] == 'assistant' %}
60
+ ### Response:
61
+ {{ message['content']|trim -}}
62
+ {% if not loop.last %}
63
+
64
+
65
+ {% endif %}
66
+ {% elif message['role'] == 'user_context' %}
67
+ ### Input:
68
+ {{ message['content']|trim -}}
69
+ {% if not loop.last %}
70
+
71
+
72
+ {% endif %}
73
+ {% endif %}
74
+ {% endfor %}
75
+ {% if add_generation_prompt and messages[-1]['role'] != 'assistant' %}
76
+ ### Response:
77
+ {% endif %}
78
+ """
79
+ ]
80
+ ),
81
+ "eot_token": "###",
82
+ },
83
+ }
84
+
85
+
86
+ class AsyncLLMWithTemplate(StatelessLLMInterface):
87
+ def __init__(
88
+ self,
89
+ model: str,
90
+ base_url: str,
91
+ llm_api_key: str = "z",
92
+ organization_id: str = "z",
93
+ project_id: str = "z",
94
+ template: str = "CHATML",
95
+ temperature: float = 1.0,
96
+ ):
97
+ """
98
+ Initializes an instance of the `AsyncLLM` class.
99
+
100
+ Parameters:
101
+ - model (str): The model to be used for language generation.
102
+ - base_url (str): The base URL for the OpenAI API.
103
+ - organization_id (str, optional): The organization ID for the OpenAI API. Defaults to "z".
104
+ - project_id (str, optional): The project ID for the OpenAI API. Defaults to "z".
105
+ - llm_api_key (str, optional): The API key for the OpenAI API. Defaults to "z".
106
+ - template (str, optional): The Jinja template to use. Defaults to "LLAMA3".
107
+ - temperature (float, optional): What sampling temperature to use, between 0 and 2. Defaults to 1.0.
108
+ """
109
+ self.completion_url = base_url
110
+ self.model = model
111
+ self.temperature = temperature
112
+ self.template = Template(TEMPLATES[template]["template"])
113
+ self.eot_token = TEMPLATES[template]["eot_token"]
114
+ self.prompt_headers = {
115
+ "Authorization": llm_api_key or "Bearer your_api_key_here"
116
+ }
117
+ logger.info(
118
+ f"Initialized AsyncLLM with the parameters: {self.completion_url} ({template})"
119
+ )
120
+
121
+ async def chat_completion(
122
+ self, messages: List[Dict[str, Any]], system: str = None
123
+ ) -> AsyncIterator[str]:
124
+ """
125
+ Generates a chat completion using the OpenAI API asynchronously.
126
+
127
+ Parameters:
128
+ - messages (List[Dict[str, Any]]): The list of messages to send to the API.
129
+ - system (str, optional): System prompt to use for this completion.
130
+
131
+ Yields:
132
+ - str: The content of each chunk from the API response.
133
+
134
+ Raises:
135
+ - APIConnectionError: When the server cannot be reached
136
+ - RateLimitError: When a 429 status code is received
137
+ - APIError: For other API-related errors
138
+ """
139
+ logger.debug(f"Messages: {messages}")
140
+ bos_token = "<|begin_of_text|>"
141
+ stream = None
142
+ try:
143
+ # If system prompt is provided, add it to the messages
144
+ messages_with_system: List[Dict[str, Any]] = messages
145
+ if system:
146
+ messages_with_system = [
147
+ {"role": "system", "content": system},
148
+ *messages,
149
+ ]
150
+ prompt = self.template.render(
151
+ messages=messages_with_system,
152
+ bos_token=bos_token,
153
+ add_generation_prompt=True,
154
+ )
155
+ data: Dict = {
156
+ "stream": True,
157
+ "temperature": self.temperature,
158
+ "prompt": prompt,
159
+ }
160
+ with requests.post(
161
+ self.completion_url, headers=self.prompt_headers, json=data, stream=True
162
+ ) as response:
163
+ for line in response.iter_lines():
164
+ if line:
165
+ line = self._clean_raw_bytes(line)
166
+ next_token = self._process_line(line)
167
+ if next_token:
168
+ if next_token == self.eot_token:
169
+ break
170
+ yield next_token
171
+ except Exception as e:
172
+ logger.error(f"LLM API WITH TEMPLATE: Error occurred: {e}")
173
+ logger.info(f"Base URL: {self.base_url}")
174
+ logger.info(f"Model: {self.model}")
175
+ logger.info(f"Messages: {messages}")
176
+ logger.info(f"temperature: {self.temperature}")
177
+ yield "Error calling the chat endpoint: Error occurred while generating response. See the logs for details."
178
+ finally:
179
+ # make sure the stream is properly closed
180
+ # so when interrupted, no more tokens will being generated.
181
+ if stream:
182
+ logger.debug("Chat completion finished.")
183
+ await stream.close()
184
+ logger.debug("Stream closed.")
185
+
186
+ def _clean_raw_bytes(self, line):
187
+ line = line.decode("utf-8")
188
+ line = line.removeprefix("data: ")
189
+ line = json.loads(line)
190
+ return line
191
+
192
+ def _process_line(self, line):
193
+ if not (("stop" in line) and (line["stop"])):
194
+ token = line["content"]
195
+ return token
src/open_llm_vtuber/agent/stateless_llm_factory.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Type
2
+
3
+ from loguru import logger
4
+
5
+ from .stateless_llm.stateless_llm_interface import StatelessLLMInterface
6
+ from .stateless_llm.stateless_llm_with_template import (
7
+ AsyncLLMWithTemplate as StatelessLLMWithTemplate,
8
+ )
9
+ from .stateless_llm.openai_compatible_llm import AsyncLLM as OpenAICompatibleLLM
10
+ from .stateless_llm.ollama_llm import OllamaLLM
11
+ from .stateless_llm.claude_llm import AsyncLLM as ClaudeLLM
12
+
13
+
14
+ class LLMFactory:
15
+ @staticmethod
16
+ def create_llm(llm_provider, **kwargs) -> Type[StatelessLLMInterface]:
17
+ """Create an LLM based on the configuration.
18
+
19
+ Args:
20
+ llm_provider: The type of LLM to create
21
+ **kwargs: Additional arguments
22
+ """
23
+ logger.info(f"Initializing LLM: {llm_provider}")
24
+
25
+ if (
26
+ llm_provider == "openai_compatible_llm"
27
+ or llm_provider == "openai_llm"
28
+ or llm_provider == "gemini_llm"
29
+ or llm_provider == "zhipu_llm"
30
+ or llm_provider == "deepseek_llm"
31
+ or llm_provider == "groq_llm"
32
+ or llm_provider == "mistral_llm"
33
+ or llm_provider == "lmstudio_llm"
34
+ ):
35
+ return OpenAICompatibleLLM(
36
+ model=kwargs.get("model"),
37
+ base_url=kwargs.get("base_url"),
38
+ llm_api_key=kwargs.get("llm_api_key"),
39
+ organization_id=kwargs.get("organization_id"),
40
+ project_id=kwargs.get("project_id"),
41
+ temperature=kwargs.get("temperature"),
42
+ )
43
+ if llm_provider == "stateless_llm_with_template":
44
+ return StatelessLLMWithTemplate(
45
+ model=kwargs.get("model"),
46
+ base_url=kwargs.get("base_url"),
47
+ llm_api_key=kwargs.get("llm_api_key"),
48
+ organization_id=kwargs.get("organization_id"),
49
+ template=kwargs.get("template"),
50
+ project_id=kwargs.get("project_id"),
51
+ )
52
+ if llm_provider == "ollama_llm":
53
+ return OllamaLLM(
54
+ model=kwargs.get("model"),
55
+ base_url=kwargs.get("base_url"),
56
+ llm_api_key=kwargs.get("llm_api_key"),
57
+ organization_id=kwargs.get("organization_id"),
58
+ project_id=kwargs.get("project_id"),
59
+ temperature=kwargs.get("temperature"),
60
+ keep_alive=kwargs.get("keep_alive"),
61
+ unload_at_exit=kwargs.get("unload_at_exit"),
62
+ )
63
+
64
+ elif llm_provider == "llama_cpp_llm":
65
+ from .stateless_llm.llama_cpp_llm import LLM as LlamaLLM
66
+
67
+ return LlamaLLM(
68
+ model_path=kwargs.get("model_path"),
69
+ )
70
+ elif llm_provider == "claude_llm":
71
+ return ClaudeLLM(
72
+ system=kwargs.get("system_prompt"),
73
+ base_url=kwargs.get("base_url"),
74
+ model=kwargs.get("model"),
75
+ llm_api_key=kwargs.get("llm_api_key"),
76
+ )
77
+ else:
78
+ raise ValueError(f"Unsupported LLM provider: {llm_provider}")
79
+
80
+
81
+ # Creating an LLM instance using a factory
82
+ # llm_instance = LLMFactory.create_llm("ollama", **config_dict)
src/open_llm_vtuber/agent/transformers.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import AsyncIterator, Tuple, Callable, List, Union, Dict, Any
2
+ from functools import wraps
3
+ from .output_types import Actions, SentenceOutput, DisplayText
4
+ from ..utils.tts_preprocessor import tts_filter as filter_text
5
+ from ..live2d_model import Live2dModel
6
+ from ..config_manager import TTSPreprocessorConfig
7
+ from ..utils.sentence_divider import SentenceDivider
8
+ from ..utils.sentence_divider import SentenceWithTags, TagState
9
+ from loguru import logger
10
+
11
+
12
+ def sentence_divider(
13
+ faster_first_response: bool = True,
14
+ segment_method: str = "pysbd",
15
+ valid_tags: List[str] = None,
16
+ ):
17
+ """
18
+ Decorator that transforms token stream into sentences with tags
19
+
20
+ Args:
21
+ faster_first_response: bool - Whether to enable faster first response
22
+ segment_method: str - Method for sentence segmentation
23
+ valid_tags: List[str] - List of valid tags to process
24
+ """
25
+
26
+ def decorator(
27
+ func: Callable[
28
+ ..., AsyncIterator[Union[str, Dict[str, Any]]]
29
+ ], # Expects str or dict
30
+ ) -> Callable[
31
+ ..., AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]
32
+ ]: # Yields SentenceWithTags or dict
33
+ @wraps(func)
34
+ async def wrapper(
35
+ *args, **kwargs
36
+ ) -> AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]:
37
+ divider = SentenceDivider(
38
+ faster_first_response=faster_first_response,
39
+ segment_method=segment_method,
40
+ valid_tags=valid_tags or [],
41
+ )
42
+ stream_from_func = func(*args, **kwargs)
43
+
44
+ # Process the mixed stream using the updated SentenceDivider
45
+ async for item in divider.process_stream(stream_from_func):
46
+ if isinstance(item, SentenceWithTags):
47
+ logger.debug(f"sentence_divider yielding sentence: {item}")
48
+ elif isinstance(item, dict):
49
+ logger.debug(f"sentence_divider yielding dict: {item}")
50
+ yield item # Yield either SentenceWithTags or dict
51
+ # Flushing is handled within divider.process_stream
52
+
53
+ return wrapper
54
+
55
+ return decorator
56
+
57
+
58
+ def actions_extractor(live2d_model: Live2dModel):
59
+ """
60
+ Decorator that extracts actions from sentences, passing through dicts.
61
+ """
62
+
63
+ def decorator(
64
+ func: Callable[
65
+ ..., AsyncIterator[Union[SentenceWithTags, Dict[str, Any]]]
66
+ ], # Input type hint
67
+ ) -> Callable[
68
+ ..., AsyncIterator[Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]]
69
+ ]: # Output type hint
70
+ @wraps(func)
71
+ async def wrapper(
72
+ *args, **kwargs
73
+ ) -> AsyncIterator[
74
+ Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]
75
+ ]: # Yield type hint
76
+ stream = func(*args, **kwargs)
77
+ async for item in stream:
78
+ if isinstance(item, SentenceWithTags):
79
+ sentence = item
80
+ actions = Actions()
81
+ # Only extract emotions for non-tag text
82
+ if not any(
83
+ tag.state in [TagState.START, TagState.END]
84
+ for tag in sentence.tags
85
+ ):
86
+ expressions = live2d_model.extract_emotion(sentence.text)
87
+ if expressions:
88
+ actions.expressions = expressions
89
+ yield sentence, actions # Yield the tuple
90
+ elif isinstance(item, dict):
91
+ # Pass through dictionaries
92
+ yield item
93
+ else:
94
+ logger.warning(
95
+ f"actions_extractor received unexpected type: {type(item)}"
96
+ )
97
+
98
+ return wrapper
99
+
100
+ return decorator
101
+
102
+
103
+ def display_processor():
104
+ """
105
+ Decorator that processes text for display, passing through dicts.
106
+ """
107
+
108
+ def decorator(
109
+ func: Callable[
110
+ ..., AsyncIterator[Union[Tuple[SentenceWithTags, Actions], Dict[str, Any]]]
111
+ ], # Input type hint
112
+ ) -> Callable[
113
+ ...,
114
+ AsyncIterator[
115
+ Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
116
+ ],
117
+ ]: # Output type hint
118
+ @wraps(func)
119
+ async def wrapper(
120
+ *args, **kwargs
121
+ ) -> AsyncIterator[
122
+ Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
123
+ ]: # Yield type hint
124
+ stream = func(*args, **kwargs)
125
+
126
+ async for item in stream:
127
+ if (
128
+ isinstance(item, tuple)
129
+ and len(item) == 2
130
+ and isinstance(item[0], SentenceWithTags)
131
+ ):
132
+ sentence, actions = item
133
+ text = sentence.text
134
+ # Handle think tag states
135
+ for tag in sentence.tags:
136
+ if tag.name == "think":
137
+ if tag.state == TagState.START:
138
+ text = "("
139
+ elif tag.state == TagState.END:
140
+ text = ")"
141
+
142
+ display = DisplayText(text=text) # Simplified DisplayText creation
143
+ yield sentence, display, actions # Yield the tuple
144
+ elif isinstance(item, dict):
145
+ # Pass through dictionaries
146
+ yield item
147
+ else:
148
+ logger.warning(
149
+ f"display_processor received unexpected type: {type(item)}"
150
+ )
151
+
152
+ return wrapper
153
+
154
+ return decorator
155
+
156
+
157
+ def tts_filter(
158
+ tts_preprocessor_config: TTSPreprocessorConfig = None,
159
+ ):
160
+ """
161
+ Decorator that filters text for TTS, passing through dicts.
162
+ Skips TTS for think tag content.
163
+ """
164
+
165
+ def decorator(
166
+ func: Callable[
167
+ ...,
168
+ AsyncIterator[
169
+ Union[Tuple[SentenceWithTags, DisplayText, Actions], Dict[str, Any]]
170
+ ],
171
+ ], # Input type hint
172
+ ) -> Callable[
173
+ ..., AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]
174
+ ]: # Output type hint
175
+ @wraps(func)
176
+ async def wrapper(
177
+ *args, **kwargs
178
+ ) -> AsyncIterator[Union[SentenceOutput, Dict[str, Any]]]: # Yield type hint
179
+ stream = func(*args, **kwargs)
180
+ config = tts_preprocessor_config or TTSPreprocessorConfig()
181
+
182
+ async for item in stream:
183
+ if (
184
+ isinstance(item, tuple)
185
+ and len(item) == 3
186
+ and isinstance(item[1], DisplayText)
187
+ ):
188
+ sentence, display, actions = item
189
+ if any(tag.name == "think" for tag in sentence.tags):
190
+ tts = ""
191
+ else:
192
+ tts = filter_text(
193
+ text=display.text,
194
+ remove_special_char=config.remove_special_char,
195
+ ignore_brackets=config.ignore_brackets,
196
+ ignore_parentheses=config.ignore_parentheses,
197
+ ignore_asterisks=config.ignore_asterisks,
198
+ ignore_angle_brackets=config.ignore_angle_brackets,
199
+ )
200
+
201
+ logger.debug(f"[{display.name}] display: {display.text}")
202
+ logger.debug(f"[{display.name}] tts: {tts}")
203
+
204
+ yield SentenceOutput(
205
+ display_text=display,
206
+ tts_text=tts,
207
+ actions=actions,
208
+ )
209
+ elif isinstance(item, dict):
210
+ # Pass through dictionaries
211
+ yield item
212
+ else:
213
+ logger.warning(f"tts_filter received unexpected type: {type(item)}")
214
+
215
+ return wrapper
216
+
217
+ return decorator
src/open_llm_vtuber/asr/__init__.py ADDED
File without changes
src/open_llm_vtuber/asr/asr_factory.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Type
2
+ from .asr_interface import ASRInterface
3
+
4
+
5
+ class ASRFactory:
6
+ @staticmethod
7
+ def get_asr_system(system_name: str, **kwargs) -> Type[ASRInterface]:
8
+ if system_name == "faster_whisper":
9
+ from .faster_whisper_asr import VoiceRecognition as FasterWhisperASR
10
+
11
+ return FasterWhisperASR(
12
+ model_path=kwargs.get("model_path"),
13
+ download_root=kwargs.get("download_root"),
14
+ language=kwargs.get("language"),
15
+ device=kwargs.get("device"),
16
+ compute_type=kwargs.get("compute_type"),
17
+ prompt=kwargs.get("prompt", None),
18
+ )
19
+ elif system_name == "whisper_cpp":
20
+ from .whisper_cpp_asr import VoiceRecognition as WhisperCPPASR
21
+
22
+ return WhisperCPPASR(**kwargs)
23
+ elif system_name == "whisper":
24
+ from .openai_whisper_asr import VoiceRecognition as WhisperASR
25
+
26
+ return WhisperASR(**kwargs)
27
+ elif system_name == "fun_asr":
28
+ from .fun_asr import VoiceRecognition as FunASR
29
+
30
+ return FunASR(
31
+ model_name=kwargs.get("model_name"),
32
+ vad_model=kwargs.get("vad_model"),
33
+ punc_model=kwargs.get("punc_model"),
34
+ ncpu=kwargs.get("ncpu"),
35
+ hub=kwargs.get("hub"),
36
+ device=kwargs.get("device"),
37
+ language=kwargs.get("language"),
38
+ use_itn=kwargs.get("use_itn"),
39
+ # sample_rate=kwargs.get("sample_rate"),
40
+ )
41
+ elif system_name == "azure_asr":
42
+ from .azure_asr import VoiceRecognition as AzureASR
43
+
44
+ return AzureASR(
45
+ subscription_key=kwargs.get("api_key"),
46
+ region=kwargs.get("region"),
47
+ languages=kwargs.get("languages", ["en-US", "zh-CN"]),
48
+ )
49
+ elif system_name == "groq_whisper_asr":
50
+ from .groq_whisper_asr import VoiceRecognition as GroqWhisperASR
51
+
52
+ return GroqWhisperASR(
53
+ api_key=kwargs.get("api_key"),
54
+ model=kwargs.get("model"),
55
+ lang=kwargs.get("lang"),
56
+ )
57
+ elif system_name == "sherpa_onnx_asr":
58
+ from .sherpa_onnx_asr import VoiceRecognition as SherpaOnnxASR
59
+
60
+ return SherpaOnnxASR(**kwargs)
61
+ else:
62
+ raise ValueError(f"Unknown ASR system: {system_name}")
src/open_llm_vtuber/asr/asr_interface.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import abc
2
+ import numpy as np
3
+ import asyncio
4
+
5
+
6
+ class ASRInterface(metaclass=abc.ABCMeta):
7
+ SAMPLE_RATE = 16000
8
+ NUM_CHANNELS = 1
9
+ SAMPLE_WIDTH = 2
10
+
11
+ async def async_transcribe_np(self, audio: np.ndarray) -> str:
12
+ """Asynchronously transcribe speech audio in numpy array format.
13
+
14
+ By default, this runs the synchronous transcribe_np in a coroutine.
15
+ Subclasses can override this method to provide true async implementation.
16
+
17
+ Args:
18
+ audio: The numpy array of the audio data to transcribe.
19
+
20
+ Returns:
21
+ str: The transcription result.
22
+ """
23
+ if audio.dtype != np.float32:
24
+ audio = audio.astype(np.float32)
25
+ return await asyncio.to_thread(self.transcribe_np, audio)
26
+
27
+ @abc.abstractmethod
28
+ def transcribe_np(self, audio: np.ndarray) -> str:
29
+ """Transcribe speech audio in numpy array format and return the transcription.
30
+
31
+ Args:
32
+ audio: The numpy array of the audio data to transcribe.
33
+ """
34
+ raise NotImplementedError
35
+
36
+ def nparray_to_audio_file(
37
+ self, audio: np.ndarray, sample_rate: int, file_path: str
38
+ ) -> None:
39
+ """Convert a numpy array of audio data to a .wav file.
40
+
41
+ Args:
42
+ audio: The numpy array of audio data.
43
+ sample_rate: The sample rate of the audio data.
44
+ file_path: The path to save the .wav file.
45
+ """
46
+ import wave
47
+
48
+ # Make sure the audio is in the range [-1, 1]
49
+ audio = np.clip(audio, -1, 1)
50
+ # Convert the audio to 16-bit PCM
51
+ audio_integer = (audio * 32767).astype(np.int16)
52
+
53
+ with wave.open(file_path, "wb") as wf:
54
+ wf.setnchannels(1)
55
+ wf.setsampwidth(2)
56
+ wf.setframerate(sample_rate)
57
+ wf.writeframes(audio_integer.tobytes())
src/open_llm_vtuber/asr/azure_asr.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Callable
3
+ import numpy as np
4
+ from loguru import logger
5
+ import azure.cognitiveservices.speech as speechsdk
6
+ from .asr_interface import ASRInterface
7
+ import soundfile as sf
8
+ import uuid
9
+ import asyncio
10
+
11
+ CACHE_DIR = "cache"
12
+
13
+
14
+ class VoiceRecognition(ASRInterface):
15
+ def __init__(
16
+ self,
17
+ subscription_key=os.getenv("AZURE_API_Key"),
18
+ region=os.getenv("AZURE_REGION"),
19
+ languages=["en-US", "zh-CN"],
20
+ callback: Callable = logger.info,
21
+ ):
22
+ if not subscription_key or not region:
23
+ raise ValueError(
24
+ "Azure Speech Services requires both subscription_key and region. "
25
+ "Please check your configuration."
26
+ )
27
+
28
+ self.subscription_key = subscription_key
29
+ self.region = region
30
+ self.callback = callback
31
+
32
+ try:
33
+ self.speech_config = speechsdk.SpeechConfig(
34
+ subscription=self.subscription_key, region=self.region
35
+ )
36
+
37
+ # Set the languages for auto detection
38
+ self.speech_config.set_property(
39
+ speechsdk.PropertyId.SpeechServiceConnection_AutoDetectSourceLanguages,
40
+ ",".join(languages),
41
+ )
42
+ except Exception as e:
43
+ logger.error(f"Failed to initialize Azure Speech Config: {e}")
44
+ raise
45
+
46
+ def _create_speech_recognizer(self, uses_default_microphone: bool = True):
47
+ """
48
+ Create a speech recognizer instance with the specified configuration.
49
+
50
+ Args:
51
+ uses_default_microphone (bool): Whether to use default microphone
52
+
53
+ Returns:
54
+ SpeechRecognizer: Configured speech recognizer instance
55
+ """
56
+ try:
57
+ audio_config = speechsdk.AudioConfig(
58
+ use_default_microphone=uses_default_microphone
59
+ )
60
+ return speechsdk.SpeechRecognizer(
61
+ speech_config=self.speech_config, audio_config=audio_config
62
+ )
63
+ except Exception as e:
64
+ logger.warning(f"Failed to create speech recognizer: {e}")
65
+ raise
66
+
67
+ async def async_transcribe_np(self, audio: np.ndarray) -> str:
68
+ """
69
+ Asynchronously transcribe audio data using Azure Speech Services with auto language detection.
70
+
71
+ Args:
72
+ audio (np.ndarray): Audio data as numpy array
73
+
74
+ Returns:
75
+ str: Transcribed text
76
+
77
+ Raises:
78
+ Exception: If transcription fails
79
+ """
80
+ temp_file = os.path.join(CACHE_DIR, f"{uuid.uuid4()}.wav")
81
+
82
+ try:
83
+ os.makedirs(CACHE_DIR, exist_ok=True)
84
+ sf.write(temp_file, audio, 16000, "PCM_16")
85
+
86
+ audio_config = speechsdk.AudioConfig(filename=temp_file)
87
+ speech_recognizer = speechsdk.SpeechRecognizer(
88
+ speech_config=self.speech_config, audio_config=audio_config
89
+ )
90
+
91
+ # Perform recognition
92
+ result = speech_recognizer.recognize_once()
93
+
94
+ if result.reason == speechsdk.ResultReason.RecognizedSpeech:
95
+ # Get detected language
96
+ detected_language = result.properties.get(
97
+ speechsdk.PropertyId.SpeechServiceConnection_AutoDetectSourceLanguageResult
98
+ )
99
+ logger.debug(f"Detected language: {detected_language}")
100
+ return result.text
101
+ elif result.reason == speechsdk.ResultReason.NoMatch:
102
+ logger.warning(
103
+ f"No speech could be recognized: {result.no_match_details}"
104
+ )
105
+ return ""
106
+ elif result.reason == speechsdk.ResultReason.Canceled:
107
+ cancellation_details = result.cancellation_details
108
+ logger.error(
109
+ f"Speech Recognition canceled: {cancellation_details.reason}"
110
+ )
111
+ if cancellation_details.reason == speechsdk.CancellationReason.Error:
112
+ logger.error(f"Error details: {cancellation_details.error_details}")
113
+ raise Exception(
114
+ f"Speech Recognition failed: {cancellation_details.reason}"
115
+ )
116
+
117
+ except Exception as e:
118
+ logger.error(f"Transcription failed: {e}")
119
+ raise
120
+ finally:
121
+ try:
122
+ if os.path.exists(temp_file):
123
+ os.remove(temp_file)
124
+ except Exception as e:
125
+ logger.debug(f"Failed to remove temporary file {temp_file}: {e}")
126
+
127
+ def transcribe_np(self, audio: np.ndarray) -> str:
128
+ """
129
+ Synchronously transcribe audio data using Azure Speech Services.
130
+
131
+ Args:
132
+ audio (np.ndarray): Audio data as numpy array
133
+
134
+ Returns:
135
+ str: Transcribed text
136
+
137
+ Raises:
138
+ Exception: If transcription fails
139
+ """
140
+ try:
141
+ try:
142
+ loop = asyncio.get_event_loop()
143
+ except RuntimeError:
144
+ loop = asyncio.new_event_loop()
145
+ asyncio.set_event_loop(loop)
146
+
147
+ # Run async method synchronously
148
+ return loop.run_until_complete(self.async_transcribe_np(audio))
149
+ except Exception as e:
150
+ logger.error(f"Transcription failed: {e}")
151
+ raise
152
+
153
+
154
+ if __name__ == "__main__":
155
+ service = VoiceRecognition()
src/open_llm_vtuber/asr/faster_whisper_asr.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from faster_whisper import WhisperModel
3
+ from .asr_interface import ASRInterface
4
+
5
+
6
+ class VoiceRecognition(ASRInterface):
7
+ BEAM_SEARCH = True
8
+ # SAMPLE_RATE # Defined in asr_interface.py
9
+
10
+ def __init__(
11
+ self,
12
+ model_path: str = "distil-medium.en",
13
+ download_root: str = None,
14
+ language: str = "en",
15
+ device: str = "auto",
16
+ compute_type: str = "int8",
17
+ prompt: str = None,
18
+ ) -> None:
19
+ self.MODEL_PATH = model_path
20
+ self.LANG = language
21
+ self.prompt = prompt
22
+ self.model = WhisperModel(
23
+ model_size_or_path=model_path,
24
+ download_root=download_root,
25
+ device=device,
26
+ compute_type=compute_type,
27
+ )
28
+
29
+ def transcribe_np(self, audio: np.ndarray) -> str:
30
+ if self.prompt:
31
+ segments, info = self.model.transcribe(
32
+ audio,
33
+ beam_size=5 if self.BEAM_SEARCH else 1,
34
+ language=self.LANG if self.LANG else None,
35
+ condition_on_previous_text=False,
36
+ initial_prompt=self.prompt,
37
+ )
38
+ else:
39
+ segments, info = self.model.transcribe(
40
+ audio,
41
+ beam_size=5 if self.BEAM_SEARCH else 1,
42
+ language=self.LANG if self.LANG else None,
43
+ condition_on_previous_text=False,
44
+ )
45
+ text = [segment.text for segment in segments]
46
+
47
+ if not text:
48
+ return ""
49
+ else:
50
+ return "".join(text)
src/open_llm_vtuber/asr/fun_asr.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ import re
4
+ import torch
5
+ import numpy as np
6
+ import soundfile as sf
7
+ from funasr import AutoModel
8
+ from .asr_interface import ASRInterface
9
+ from typing import Optional
10
+
11
+ # Try to import modelscope for local cache detection
12
+ try:
13
+ from modelscope.hub.snapshot_download import snapshot_download
14
+
15
+ MODEL_SCOPE_DOWNLOAD_AVAILABLE = True
16
+ except ImportError:
17
+ print("Warning: Unable to import modelscope.hub.snapshot_download.")
18
+ MODEL_SCOPE_DOWNLOAD_AVAILABLE = False
19
+
20
+ # Model alias to actual ModelScope ID mapping table
21
+ MODEL_ALIAS_TO_FULL_ID_MAP = {
22
+ "paraformer-zh": "iic/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
23
+ "paraformer-zh-spk": "iic/speech_paraformer-large-vad-punc-spk_asr_nat-zh-cn",
24
+ "paraformer-zh-online": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online",
25
+ "paraformer-en": "iic/speech_paraformer-large-vad-punc_asr_nat-en-16k-common-vocab10020",
26
+ "conformer-en": "iic/speech_conformer_asr-en-16k-vocab4199-pytorch",
27
+ "ct-punc": "iic/punc_ct-transformer_cn-en-common-vocab471067-large",
28
+ "fsmn-vad": "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
29
+ "fa-zh": "iic/speech_timestamp_prediction-v1-16k-offline",
30
+ "SenseVoiceSmall": "iic/SenseVoiceSmall",
31
+ "iic/SenseVoiceSmall": "iic/SenseVoiceSmall",
32
+ }
33
+
34
+
35
+ # paraformer-zh is a multi-functional asr model
36
+ # use vad, punc, spk or not as you need
37
+
38
+
39
+ class VoiceRecognition(ASRInterface):
40
+ def __init__(
41
+ self,
42
+ model_name: str = "iic/SenseVoiceSmall",
43
+ language: str = "auto",
44
+ vad_model: str = "fsmn-vad",
45
+ punc_model: str = "ct-punc",
46
+ ncpu: int = None,
47
+ hub: str = None,
48
+ device: str = "cpu",
49
+ disable_update: bool = True,
50
+ sample_rate: int = 16000,
51
+ use_itn: bool = False,
52
+ ) -> None:
53
+ # Resolve model paths
54
+ final_model_input = self._get_final_model_input(model_name)
55
+ final_vad_input = self._get_final_model_input(vad_model) if vad_model else None
56
+ final_punc_input = (
57
+ self._get_final_model_input(punc_model) if punc_model else None
58
+ )
59
+
60
+ self.model = AutoModel(
61
+ model=final_model_input,
62
+ vad_model=final_vad_input,
63
+ ncpu=ncpu,
64
+ hub=hub,
65
+ device=device,
66
+ disable_update=disable_update,
67
+ punc_model=final_punc_input,
68
+ # spk_model="cam++",
69
+ )
70
+ self.SAMPLE_RATE = sample_rate
71
+ self.use_itn = use_itn
72
+ self.language = language
73
+
74
+ def _get_final_model_input(self, alias_or_id: Optional[str]) -> Optional[str]:
75
+ """
76
+ Process model input function:
77
+ 1. Check mapping table to get canonical ModelScope ID.
78
+ 2. Try to get local path using snapshot_download.
79
+ 3. If local path is valid, return local path, otherwise return canonical ModelScope ID.
80
+ """
81
+ if not alias_or_id:
82
+ return None
83
+
84
+ # Get canonical ModelScope ID from mapping table
85
+ resolved_id = MODEL_ALIAS_TO_FULL_ID_MAP.get(alias_or_id, alias_or_id)
86
+ final_input_for_automodel = resolved_id # Default to use resolved ID
87
+
88
+ # Try to get local path using snapshot_download
89
+ if MODEL_SCOPE_DOWNLOAD_AVAILABLE:
90
+ try:
91
+ local_path = snapshot_download(resolved_id, local_files_only=True)
92
+ if os.path.exists(local_path): # Double check path exists
93
+ final_input_for_automodel = local_path # Use local path if found
94
+ # print(f"Successfully resolved '{resolved_id}' to local path: {local_path}")
95
+ except ValueError:
96
+ # Not found in local cache, use original ID
97
+ pass
98
+ except Exception as e:
99
+ print(f"Error occurred while checking '{resolved_id}': {e}")
100
+
101
+ return final_input_for_automodel
102
+
103
+ def transcribe_np(self, audio: np.ndarray) -> str:
104
+ audio_tensor = torch.tensor(audio, dtype=torch.float32)
105
+
106
+ res = self.model.generate(
107
+ input=audio_tensor,
108
+ batch_size_s=300,
109
+ use_itn=self.use_itn,
110
+ language=self.language,
111
+ )
112
+
113
+ full_text = res[0]["text"]
114
+
115
+ # SenseVoiceSmall may spits out some tags
116
+ # like this: '<|zh|><|NEUTRAL|><|Speech|><|woitn|>欢迎大家来体验达摩院推出的语音识别模型'
117
+ # we should remove those tags from the result
118
+
119
+ # remove tags
120
+ full_text = re.sub(r"<\|.*?\|>", "", full_text)
121
+ # the tags can also look like '< | en | > < | EMO _ UNKNOWN | > < | S pe ech | > < | wo itn | > ', so...
122
+ full_text = re.sub(r"< \|.*?\| >", "", full_text)
123
+
124
+ return full_text.strip()
125
+
126
+ def _numpy_to_wav_in_memory(self, numpy_array: np.ndarray, sample_rate):
127
+ memory_file = io.BytesIO()
128
+ sf.write(memory_file, numpy_array, sample_rate, format="WAV")
129
+ memory_file.seek(0)
130
+
131
+ return memory_file
src/open_llm_vtuber/asr/groq_whisper_asr.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import wave
3
+ import numpy as np
4
+ from loguru import logger
5
+ from groq import Groq
6
+ from .asr_interface import ASRInterface
7
+
8
+
9
+ class VoiceRecognition(ASRInterface):
10
+ # sample_rate, n_channels, and sampwidth are defined in asr_interface.py
11
+
12
+ def __init__(
13
+ self, api_key: str, model: str = "distil-whisper-large-v3-en", lang: str = "en"
14
+ ) -> None:
15
+ logger.info("Initializing Groq ASR...")
16
+ self.client = Groq(api_key=api_key)
17
+ self.lang = lang
18
+ self.model = model
19
+
20
+ def transcribe_np(self, audio: np.ndarray) -> str:
21
+ """Transcribe speech audio in numpy array format and return the transcription.
22
+
23
+ Args:
24
+ audio: The numpy array of the audio data to transcribe.
25
+ """
26
+
27
+ logger.info("Transcribing audio (GroqWhisperASR)...")
28
+
29
+ # Turn the audio into an audio file
30
+ # Make sure the audio is in the range [-1, 1]
31
+ audio = np.clip(audio, -1, 1)
32
+ # Convert the audio to 16-bit PCM
33
+ audio_integer = (audio * 32767).astype(np.int16)
34
+
35
+ # groq api requires a file-like object for the audio data, so we use a BytesIO object
36
+ audio_buffer = io.BytesIO()
37
+
38
+ with wave.open(audio_buffer, "wb") as wf:
39
+ wf.setnchannels(self.NUM_CHANNELS)
40
+ wf.setsampwidth(self.SAMPLE_WIDTH)
41
+ wf.setframerate(self.SAMPLE_RATE)
42
+ wf.writeframes(audio_integer.tobytes())
43
+
44
+ audio_buffer.seek(0)
45
+
46
+ # Transcribe the audio with the BytesIO object
47
+ transcription = self.client.audio.transcriptions.create(
48
+ file=("audio.wav", audio_buffer.read()),
49
+ model=self.model,
50
+ # prompt="Specify context or spelling",
51
+ response_format="text",
52
+ language=self.lang,
53
+ temperature=0.0,
54
+ )
55
+
56
+ return transcription
src/open_llm_vtuber/asr/openai_whisper_asr.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import whisper
3
+ from .asr_interface import ASRInterface
4
+
5
+
6
+ class VoiceRecognition(ASRInterface):
7
+ def __init__(
8
+ self,
9
+ name: str = "base",
10
+ download_root: str = None,
11
+ device="cpu",
12
+ prompt: str = None,
13
+ ) -> None:
14
+ self.model = whisper.load_model(
15
+ name=name,
16
+ device=device,
17
+ download_root=download_root,
18
+ )
19
+ self.prompt = prompt
20
+
21
+ def transcribe_np(self, audio: np.ndarray) -> str:
22
+ if self.prompt is not None:
23
+ result = self.model.transcribe(audio, initial_prompt=self.prompt)
24
+ else:
25
+ result = self.model.transcribe(audio)
26
+ full_text = result["text"]
27
+ return full_text
src/open_llm_vtuber/asr/sherpa_onnx_asr.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import sherpa_onnx
4
+ from loguru import logger
5
+ from .asr_interface import ASRInterface
6
+ from .utils import download_and_extract, check_and_extract_local_file
7
+ import onnxruntime
8
+
9
+
10
+ class VoiceRecognition(ASRInterface):
11
+ def __init__(
12
+ self,
13
+ model_type: str = "paraformer", # or "transducer", "nemo_ctc", "wenet_ctc", "whisper", "tdnn_ctc", "sense_voice", "fire_red_asr"
14
+ encoder: str = None, # Path to the encoder model, used with transducer
15
+ decoder: str = None, # Path to the decoder model, used with transducer
16
+ joiner: str = None, # Path to the joiner model, used with transducer
17
+ paraformer: str = None, # Path to the model.onnx from Paraformer
18
+ nemo_ctc: str = None, # Path to the model.onnx from NeMo CTC
19
+ wenet_ctc: str = None, # Path to the model.onnx from WeNet CTC
20
+ tdnn_model: str = None, # Path to the model.onnx for the tdnn model of the yesno recipe
21
+ whisper_encoder: str = None, # Path to whisper encoder model
22
+ whisper_decoder: str = None, # Path to whisper decoder model
23
+ sense_voice: str = None, # Path to the model.onnx from SenseVoice
24
+ fire_red_asr_encoder: str = None, # Path to FireRedASR encoder model
25
+ fire_red_asr_decoder: str = None, # Path to FireRedASR decoder model
26
+ tokens: str = None, # Path to tokens.txt
27
+ hotwords_file: str = "", # Path to hotwords file
28
+ hotwords_score: float = 1.5, # Hotwords score
29
+ modeling_unit: str = "", # Modeling unit for hotwords
30
+ bpe_vocab: str = "", # Path to bpe vocabulary, used with hotwords
31
+ num_threads: int = 1, # Number of threads for neural network computation
32
+ whisper_language: str = "", # Language for whisper model
33
+ whisper_task: str = "transcribe", # Task for whisper model (transcribe or translate)
34
+ whisper_tail_paddings: int = -1, # Tail padding frames for whisper model
35
+ blank_penalty: float = 0.0, # Penalty for blank symbol
36
+ decoding_method: str = "greedy_search", # Decoding method (greedy_search or modified_beam_search)
37
+ debug: bool = False, # Show debug messages
38
+ sample_rate: int = 16000, # Sample rate
39
+ feature_dim: int = 80, # Feature dimension
40
+ use_itn: bool = True, # Use ITN for SenseVoice models
41
+ provider: str = "cpu", # Provider for inference (cpu or cuda)
42
+ ) -> None:
43
+ self.model_type = model_type
44
+ self.encoder = encoder
45
+ self.decoder = decoder
46
+ self.joiner = joiner
47
+ self.paraformer = paraformer
48
+ self.nemo_ctc = nemo_ctc
49
+ self.wenet_ctc = wenet_ctc
50
+ self.tdnn_model = tdnn_model
51
+ self.whisper_encoder = whisper_encoder
52
+ self.whisper_decoder = whisper_decoder
53
+ self.sense_voice: str = sense_voice
54
+ self.fire_red_asr_encoder = fire_red_asr_encoder
55
+ self.fire_red_asr_decoder = fire_red_asr_decoder
56
+ self.tokens = tokens
57
+ self.hotwords_file = hotwords_file
58
+ self.hotwords_score = hotwords_score
59
+ self.modeling_unit = modeling_unit
60
+ self.bpe_vocab = bpe_vocab
61
+ self.num_threads = num_threads
62
+ self.whisper_language = whisper_language
63
+ self.whisper_task = whisper_task
64
+ self.whisper_tail_paddings = whisper_tail_paddings
65
+ self.blank_penalty = blank_penalty
66
+ self.decoding_method = decoding_method
67
+ self.debug = debug
68
+ self.SAMPLE_RATE = sample_rate
69
+ self.feature_dim = feature_dim
70
+ self.use_itn = use_itn
71
+
72
+ # we need to find a way to get cuda version of sherpa-onnx before we can
73
+ # use the gpu provider.
74
+ self.provider = provider
75
+ if self.provider == "cuda":
76
+ try:
77
+ if "CUDAExecutionProvider" not in onnxruntime.get_available_providers():
78
+ logger.warning(
79
+ "CUDA provider not available for ONNX. Falling back to CPU."
80
+ )
81
+ self.provider = "cpu"
82
+ except ImportError:
83
+ logger.warning("ONNX Runtime not installed. Falling back to CPU.")
84
+ self.provider = "cpu"
85
+ logger.info(f"Sherpa-Onnx-ASR: Using {self.provider} for inference")
86
+
87
+ self.recognizer = self._create_recognizer()
88
+
89
+ def _create_recognizer(self):
90
+ if self.model_type == "transducer":
91
+ recognizer = sherpa_onnx.OfflineRecognizer.from_transducer(
92
+ encoder=self.encoder,
93
+ decoder=self.decoder,
94
+ joiner=self.joiner,
95
+ tokens=self.tokens,
96
+ num_threads=self.num_threads,
97
+ sample_rate=self.SAMPLE_RATE,
98
+ feature_dim=self.feature_dim,
99
+ decoding_method=self.decoding_method,
100
+ hotwords_file=self.hotwords_file,
101
+ hotwords_score=self.hotwords_score,
102
+ modeling_unit=self.modeling_unit,
103
+ bpe_vocab=self.bpe_vocab,
104
+ blank_penalty=self.blank_penalty,
105
+ debug=self.debug,
106
+ provider=self.provider,
107
+ )
108
+ elif self.model_type == "paraformer":
109
+ recognizer = sherpa_onnx.OfflineRecognizer.from_paraformer(
110
+ paraformer=self.paraformer,
111
+ tokens=self.tokens,
112
+ num_threads=self.num_threads,
113
+ sample_rate=self.SAMPLE_RATE,
114
+ feature_dim=self.feature_dim,
115
+ decoding_method=self.decoding_method,
116
+ debug=self.debug,
117
+ provider=self.provider,
118
+ )
119
+ elif self.model_type == "nemo_ctc":
120
+ recognizer = sherpa_onnx.OfflineRecognizer.from_nemo_ctc(
121
+ model=self.nemo_ctc,
122
+ tokens=self.tokens,
123
+ num_threads=self.num_threads,
124
+ sample_rate=self.SAMPLE_RATE,
125
+ feature_dim=self.feature_dim,
126
+ decoding_method=self.decoding_method,
127
+ debug=self.debug,
128
+ provider=self.provider,
129
+ )
130
+ elif self.model_type == "wenet_ctc":
131
+ recognizer = sherpa_onnx.OfflineRecognizer.from_wenet_ctc(
132
+ model=self.wenet_ctc,
133
+ tokens=self.tokens,
134
+ num_threads=self.num_threads,
135
+ sample_rate=self.SAMPLE_RATE,
136
+ feature_dim=self.feature_dim,
137
+ decoding_method=self.decoding_method,
138
+ debug=self.debug,
139
+ provider=self.provider,
140
+ )
141
+ elif self.model_type == "whisper":
142
+ recognizer = sherpa_onnx.OfflineRecognizer.from_whisper(
143
+ encoder=self.whisper_encoder,
144
+ decoder=self.whisper_decoder,
145
+ tokens=self.tokens,
146
+ num_threads=self.num_threads,
147
+ decoding_method=self.decoding_method,
148
+ debug=self.debug,
149
+ language=self.whisper_language,
150
+ task=self.whisper_task,
151
+ tail_paddings=self.whisper_tail_paddings,
152
+ provider=self.provider,
153
+ )
154
+ elif self.model_type == "tdnn_ctc":
155
+ recognizer = sherpa_onnx.OfflineRecognizer.from_tdnn_ctc(
156
+ model=self.tdnn_model,
157
+ tokens=self.tokens,
158
+ sample_rate=self.SAMPLE_RATE,
159
+ feature_dim=self.feature_dim,
160
+ num_threads=self.num_threads,
161
+ decoding_method=self.decoding_method,
162
+ debug=self.debug,
163
+ provider=self.provider,
164
+ )
165
+ elif self.model_type == "sense_voice":
166
+ if not self.sense_voice or not os.path.isfile(self.sense_voice):
167
+ if self.sense_voice.startswith(
168
+ "./models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
169
+ ):
170
+ logger.warning(
171
+ "SenseVoice model not found. Downloading the model..."
172
+ )
173
+
174
+ url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2"
175
+ output_dir = "./models"
176
+ # check the local file first before download
177
+ local_result = check_and_extract_local_file(url, output_dir)
178
+
179
+ if local_result is None:
180
+ logger.info("Local file not found. Downloading...")
181
+ download_and_extract(url, output_dir)
182
+ else:
183
+ logger.info("Local file found. Using existing file.")
184
+ # download_and_extract(
185
+ # url="https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2",
186
+ # output_dir="./models",
187
+ # )
188
+ else:
189
+ logger.critical(
190
+ "The SenseVoice model is missing. Please provide the path to the model.onnx file."
191
+ )
192
+ recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
193
+ model=self.sense_voice,
194
+ tokens=self.tokens,
195
+ num_threads=self.num_threads,
196
+ use_itn=self.use_itn,
197
+ debug=self.debug,
198
+ provider=self.provider,
199
+ )
200
+ elif self.model_type == "fire_red_asr":
201
+ recognizer = sherpa_onnx.OfflineRecognizer.from_fire_red_asr(
202
+ encoder=self.fire_red_asr_encoder,
203
+ decoder=self.fire_red_asr_decoder,
204
+ tokens=self.tokens,
205
+ num_threads=self.num_threads,
206
+ decoding_method=self.decoding_method,
207
+ debug=self.debug,
208
+ provider=self.provider,
209
+ )
210
+ else:
211
+ raise ValueError(f"Invalid model type: {self.model_type}")
212
+
213
+ return recognizer
214
+
215
+ def transcribe_np(self, audio: np.ndarray) -> str:
216
+ stream = self.recognizer.create_stream()
217
+ stream.accept_waveform(self.SAMPLE_RATE, audio)
218
+ self.recognizer.decode_streams([stream])
219
+ return stream.result.text
src/open_llm_vtuber/asr/utils.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import tarfile
4
+ from pathlib import Path
5
+ from tqdm import tqdm
6
+ from loguru import logger
7
+
8
+
9
+ def get_github_asset_url(owner, repo, release_tag, filename_without_ext):
10
+ """
11
+ Fetch the URL of a GitHub release asset by its filename (without extension).
12
+
13
+ Args:
14
+ owner (str): The owner of the repository.
15
+ repo (str): The name of the repository.
16
+ release_tag (str): The tag of the release.
17
+ filename_without_ext (str): The filename to search for (without extension).
18
+
19
+ Returns:
20
+ str: The download URL of the matched asset, or None if no match is found.
21
+ """
22
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{release_tag}"
23
+ headers = {} # Add authentication headers if needed
24
+
25
+ try:
26
+ # Make a GET request to fetch release data
27
+ response = requests.get(url, headers=headers)
28
+ response.raise_for_status()
29
+
30
+ # Parse the JSON response
31
+ release_data = response.json()
32
+ assets = release_data.get("assets", [])
33
+
34
+ # Look for a matching file
35
+ for asset in assets:
36
+ if asset["name"].startswith(filename_without_ext):
37
+ logger.info(f"Match found: {asset['name']}")
38
+ return asset["browser_download_url"]
39
+
40
+ # If no match found, log the error
41
+ logger.error(
42
+ f"No match found for filename: {filename_without_ext} in release {release_tag}."
43
+ )
44
+ return None
45
+
46
+ except requests.exceptions.RequestException as e:
47
+ logger.error(f"An error occurred while fetching release data: {e}")
48
+ return None
49
+
50
+
51
+ def download_and_extract(url: str, output_dir: str) -> Path:
52
+ """
53
+ Download a file from a URL and extract it if it is a tar.bz2 archive.
54
+
55
+ Args:
56
+ url (str): The URL to download the file from.
57
+ output_dir (str): The directory to save the downloaded file.
58
+
59
+ Returns:
60
+ Path: Path to the extracted directory if it's a tar.bz2 file,
61
+ otherwise Path to the downloaded file.
62
+ """
63
+ # Create the output directory if it doesn't exist
64
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
65
+
66
+ # Get the file name from the URL
67
+ file_name = url.split("/")[-1]
68
+ file_path = os.path.join(output_dir, file_name)
69
+
70
+ # Extract the root directory name from the filename (removing .tar.bz2)
71
+ root_dir = file_name.replace(".tar.bz2", "")
72
+ extracted_dir_path = Path(output_dir) / root_dir
73
+
74
+ # Check if the extracted directory already exists
75
+ if extracted_dir_path.exists():
76
+ logger.info(
77
+ f"✅ The directory {extracted_dir_path} already exists. I would assume that the model is already downloaded and we are ready to go. Skipping download and extraction."
78
+ )
79
+ return extracted_dir_path
80
+
81
+ # Download the file
82
+ logger.info(f"🏃‍♂️Downloading {url} to {file_path}...")
83
+ response = requests.get(url, stream=True)
84
+ response.raise_for_status() # Raise an error for bad status codes
85
+ total_size = int(response.headers.get("content-length", 0))
86
+ logger.debug(f"Total file size: {total_size / 1024 / 1024:.2f} MB")
87
+
88
+ with (
89
+ open(file_path, "wb") as f,
90
+ tqdm(
91
+ desc=file_name,
92
+ total=total_size,
93
+ unit="iB",
94
+ unit_scale=True,
95
+ unit_divisor=1024,
96
+ ) as pbar,
97
+ ):
98
+ for chunk in response.iter_content(chunk_size=8192):
99
+ size = f.write(chunk)
100
+ pbar.update(size)
101
+
102
+ logger.info(f"Downloaded {file_name} successfully.")
103
+
104
+ # Extract the tar.bz2 file
105
+ if file_name.endswith(".tar.bz2"):
106
+ logger.info(f"Extracting {file_name}...")
107
+ with tarfile.open(file_path, "r:bz2") as tar:
108
+ tar.extractall(path=output_dir)
109
+ logger.info("Extraction completed.")
110
+
111
+ # Delete the compressed file
112
+ os.remove(file_path)
113
+ logger.debug(f"Deleted the compressed file: {file_name}")
114
+
115
+ return extracted_dir_path
116
+ else:
117
+ logger.warning("The downloaded file is not a tar.bz2 archive.")
118
+ return Path(file_path)
119
+
120
+
121
+ def check_and_extract_local_file(url: str, output_dir: str) -> Path | None:
122
+ """
123
+ Check if a local file exists and extract it if it is a tar.bz2 archive.
124
+
125
+ Args:
126
+ url (str): The URL of the file.
127
+ output_dir (str): The directory to save the extracted files.
128
+
129
+ Returns:
130
+ Path | None: Path to the extracted directory if it's a tar.bz2 file,
131
+ otherwise None.
132
+ """
133
+ # Get the file name from the URL
134
+ file_name = url.split("/")[-1]
135
+ compressed_path = Path(output_dir) / file_name
136
+
137
+ # Check if the compressed file exists and is a tar.bz2 archive
138
+ extracted_dir = Path(output_dir) / file_name.replace(".tar.bz2", "")
139
+
140
+ if extracted_dir.exists():
141
+ logger.info(
142
+ f"✅ Extracted directory exists: {extracted_dir}, no operation needed."
143
+ )
144
+ return extracted_dir
145
+
146
+ if compressed_path.exists() and file_name.endswith(".tar.bz2"):
147
+ logger.info(f"🔍 Found local archive file: {compressed_path}")
148
+
149
+ try:
150
+ logger.info("⏳ Extracting archive file...")
151
+ with tarfile.open(compressed_path, "r:bz2") as tar:
152
+ tar.extractall(path=output_dir)
153
+ logger.success(f"Extracted archive to the path: {extracted_dir}")
154
+ os.remove(compressed_path) # Remove the compressed file
155
+ return extracted_dir
156
+ except Exception as e:
157
+ logger.error(f"Fail to extract file: {str(e)}")
158
+ return None
159
+
160
+ logger.warning(f"Local file not found or not a tar.bz2 archive: {compressed_path}")
161
+ return None
162
+
163
+
164
+ if __name__ == "__main__":
165
+ url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2"
166
+ output_dir = "./models"
167
+
168
+ # Try local extraction first.
169
+ local_result = check_and_extract_local_file(url, output_dir)
170
+
171
+ # Download if not available locally.
172
+ if local_result is None:
173
+ logger.info("Local archive not found. Starting download...")
174
+ download_and_extract(url, output_dir)
175
+ else:
176
+ logger.info("Extraction completed using local file.")
src/open_llm_vtuber/asr/whisper_cpp_asr.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pywhispercpp.model import Model
2
+
3
+ import numpy as np
4
+ from loguru import logger
5
+ from .asr_interface import ASRInterface
6
+
7
+
8
+ class VoiceRecognition(ASRInterface):
9
+ def __init__(
10
+ self,
11
+ model_name: str = "base",
12
+ model_dir="asr/models",
13
+ language: str = "en",
14
+ print_realtime=False,
15
+ print_progress=False,
16
+ prompt: str = None,
17
+ ) -> None:
18
+ self.model = Model(
19
+ model=model_name,
20
+ models_dir=model_dir,
21
+ language=language,
22
+ print_realtime=print_realtime,
23
+ print_progress=print_progress,
24
+ )
25
+ self.prompt = prompt
26
+
27
+ def transcribe_np(self, audio: np.ndarray) -> str:
28
+ if self.prompt is not None:
29
+ segments = self.model.transcribe(
30
+ audio, new_segment_callback=logger.info, initial_prompt=self.prompt
31
+ )
32
+ else:
33
+ segments = self.model.transcribe(audio, new_segment_callback=logger.info)
34
+ full_text = ""
35
+ for segment in segments:
36
+ full_text += segment.text
37
+ return full_text
src/open_llm_vtuber/audio_manager.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import uuid
3
+ import unicodedata
4
+
5
+ class AudioManager:
6
+ def __init__(self, tts, live2d, translator, config, verbose=False):
7
+ self.tts = tts
8
+ self.live2d = live2d
9
+ self.translator = translator
10
+ self.config = config
11
+ self.verbose = verbose
12
+ self.remove_special_char = config.get("REMOVE_SPECIAL_CHAR", True)
13
+
14
+ def clean_text(self, text: str) -> str:
15
+ text = re.sub(r'[^\u4e00-\u9fffA-Za-z0-9,]', ' ', text)
16
+
17
+ if self.remove_special_char:
18
+ text = self.remove_special_characters(text)
19
+
20
+ return text
21
+
22
+ def remove_special_characters(self, text: str) -> str:
23
+ """Filter text to remove all non-letter, non-number, and non-punctuation characters."""
24
+ normalized_text = unicodedata.normalize("NFKC", text)
25
+
26
+ def is_valid_char(char: str) -> bool:
27
+ category = unicodedata.category(char)
28
+ return (
29
+ category.startswith("L")
30
+ or category.startswith("N")
31
+ or category.startswith("P")
32
+ or char.isspace()
33
+ )
34
+
35
+ filtered_text = "".join(char for char in normalized_text if is_valid_char(char))
36
+ return filtered_text
37
+
38
+ def generate_audio_file(self, sentence: str, file_name_no_ext: str) -> str | None:
39
+ """
40
+ Generate an audio file from a given sentence using the TTS engine.
41
+
42
+ Parameters:
43
+ - sentence (str): The sentence to generate audio for
44
+ - file_name_no_ext (str): The name of the audio file (without extension)
45
+
46
+ Returns:
47
+ - str or None: The path of the generated audio file, or None if the sentence iempty
48
+ """
49
+ sentence = self.clean_text(sentence)
50
+ if self.verbose:
51
+ print(f">> generating {file_name_no_ext}...")
52
+
53
+ if not self.tts:
54
+ return None
55
+
56
+ if self.live2d:
57
+ sentence = self.live2d.remove_emotion_keywords(sentence)
58
+
59
+ if sentence.strip() == "":
60
+ return None
61
+
62
+ return self.tts.generate_audio(sentence, file_name_no_ext=file_name_no_ext)
63
+
64
+ def play_audio_file(self, sentence: str | None, filepath: str | None, instrument_filepath: str | None = None) -> None:
65
+ """
66
+ Play the audio file located at the given filepath.
67
+ """
68
+ if filepath is None:
69
+ print("No audio to be streamed. Response is empty.")
70
+ return
71
+
72
+ if sentence is None:
73
+ sentence = ""
74
+
75
+ try:
76
+ if self.verbose:
77
+ print(f">> Playing {filepath}...")
78
+ self.tts.play_audio_file_local(filepath)
79
+
80
+ self.tts.remove_file(filepath, verbose=self.verbose)
81
+ except ValueError as e:
82
+ if str(e) == "Audio is empty or all zero.":
83
+ print("No audio to be streamed. Response is empty.")
84
+ else:
85
+ raise e
86
+ except Exception as e:
87
+ print(f"Error playing the audio file {filepath}: {e}")
88
+
89
+ def play_text(self, text: str) -> None:
90
+ if not text.strip():
91
+ print("No text to play.")
92
+ return
93
+
94
+ sentences = re.split(r'(?<=[.!?。!?])\s*', text)
95
+ sentences = [s for s in sentences if s.strip()]
96
+
97
+ for sentence in sentences:
98
+ tts_target_sentence = self.live2d.remove_emotion_keywords(sentence)
99
+
100
+ if self.translator and self.config.get("TRANSLATE_AUDIO", False):
101
+ print("Translating...")
102
+ tts_target_sentence = self.translator.translate (tts_target_sentence)
103
+ print(f"Translated: {tts_target_sentence}")
104
+
105
+ audio_filepath = self.generate_audio_file(
106
+ tts_target_sentence, file_name_no_ext=f"temp_text_{uuid.uuid4()}"
107
+ )
108
+
109
+ if audio_filepath:
110
+ self.play_audio_file(sentence=sentence, filepath=audio_filepath)
111
+ else:
112
+ print("No audio generated for sentence.")
113
+
114
+