├── src ├── lib │ ├── __init__.py │ ├── tts │ │ ├── tmp │ │ │ └── .gitignore │ │ ├── tts.sh │ │ └── tts.py │ ├── emoji-to-emote.csv │ ├── emotes.py │ ├── youtube.py │ ├── utils.py │ ├── chains.py │ └── gptuber.py ├── agent.py └── server.py ├── public ├── img │ └── streamer │ │ ├── funny.jpeg │ │ ├── happy.jpeg │ │ └── default.jpeg └── index.html ├── requirements.txt ├── .gitignore ├── LICENSE └── README.md /src/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/tts/tmp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/img/streamer/funny.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karakuri-ai/gptuber-by-langchain/HEAD/public/img/streamer/funny.jpeg -------------------------------------------------------------------------------- /public/img/streamer/happy.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karakuri-ai/gptuber-by-langchain/HEAD/public/img/streamer/happy.jpeg -------------------------------------------------------------------------------- /public/img/streamer/default.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karakuri-ai/gptuber-by-langchain/HEAD/public/img/streamer/default.jpeg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | emoji==2.2.0 2 | google-search-results==2.4.1 3 | langchain==0.0.44 4 | mypy==0.991 5 | mecab-python3==0.7 6 | numpy==1.24.0 7 | openai==0.25.0 8 | pandas==1.5.2 9 | pydantic==1.10.2 10 | requests==2.28.1 11 | types-requests==2.28.11.7 12 | websockets==10.4 13 | -------------------------------------------------------------------------------- /src/lib/emoji-to-emote.csv: -------------------------------------------------------------------------------- 1 | emoji,emote 2 | 😀,default.jpeg 3 | 😏,default.jpeg 4 | 😐,default.jpeg 5 | 😑,default.jpeg 6 | 😔,default.jpeg 7 | 😕,default.jpeg 8 | 😁,happy.jpeg 9 | 😃,happy.jpeg 10 | 😄,happy.jpeg 11 | 😊,happy.jpeg 12 | 😋,happy.jpeg 13 | 😛,funny.jpeg 14 | 😜,funny.jpeg 15 | 😝,funny.jpeg 16 | 🙄,funny.jpeg 17 | 🤑,funny.jpeg 18 | -------------------------------------------------------------------------------- /src/lib/tts/tts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cat < ./tmp/_.json 3 | { 4 | "input": { 5 | "text": "$1" 6 | }, 7 | "voice": { 8 | "languageCode": "ja-JP", 9 | "name": "ja-JP-Neural2-B" 10 | }, 11 | "audioConfig": { 12 | "audioEncoding": "MP3", 13 | "pitch": 4 14 | } 15 | } 16 | EOF 17 | curl -s -X POST -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" -H "Content-Type: application/json" -d @./tmp/_.json https://texttospeech.googleapis.com/v1/text:synthesize | jq -r .audioContent | base64 -d > ./tmp/_.mp3 18 | echo "$(pwd)/tmp/_.mp3" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | # lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | .tox/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | # Rope 43 | .ropeproject 44 | 45 | # Django stuff: 46 | *.log 47 | *.pot 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Karakuri, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/emotes.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Union 3 | from collections import Counter 4 | 5 | import pandas as pd 6 | 7 | from lib.utils import extract_emojis 8 | 9 | 10 | class EmojiToEmoteConverter: 11 | def __init__(self, path_to_csv: Union[str, Path], path_to_emotes: Union[str, Path]): 12 | df = pd.read_csv(path_to_csv) 13 | filenames = set(f.name for f in Path(path_to_emotes).glob("*") if f.is_file()) 14 | # 一致するファイル名のみを残す 15 | df = df[df["emote"].isin(filenames)] 16 | self.dictionary = dict(zip(df["emoji"], df["emote"])) 17 | 18 | def convert(self, emoji: str) -> Optional[str]: 19 | return self.dictionary.get(emoji) 20 | 21 | 22 | emoji_to_emote_converter = EmojiToEmoteConverter( 23 | path_to_csv="./lib/emoji-to-emote.csv", 24 | path_to_emotes="../public/img/streamer/" 25 | ) 26 | 27 | 28 | def determine_emote_from_text(text: str) -> Optional[str]: 29 | emojis = extract_emojis(text) 30 | if len(emojis) == 0: 31 | return None 32 | emote = Counter(map(emoji_to_emote_converter.convert, emojis)).most_common(1)[0][0] # type: ignore 33 | return emote 34 | -------------------------------------------------------------------------------- /src/agent.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import subprocess 4 | import time 5 | from typing import Awaitable, Callable, Optional 6 | from typing_extensions import Protocol 7 | 8 | from langchain.agents import load_tools 9 | from langchain.agents import initialize_agent 10 | from langchain.llms import OpenAI 11 | 12 | from lib.utils import remove_control_characters 13 | 14 | 15 | class FnSmartAgent(Protocol): 16 | def __call__(self, query: str, fn_report: Optional[Callable[[str], None]] = None) -> Awaitable[None]: 17 | ... 18 | 19 | 20 | async def execute_agent_with_subprocess(query: str, fn_report: Optional[Callable[[str], None]] = None) -> None: 21 | proc = subprocess.Popen( 22 | ["python", "-u", "./agent.py", query], 23 | stdout=subprocess.PIPE, 24 | text=True 25 | ) 26 | t0 = time.time() 27 | while True: 28 | if proc.stdout is None: 29 | break 30 | line = proc.stdout.readline() 31 | if line == "" and proc.poll() is not None: 32 | break 33 | if line: 34 | if fn_report is not None: 35 | fn_report(remove_control_characters(line)) 36 | elapsed = time.time() - t0 37 | if elapsed > 80: 38 | # 80 秒以上かかっている場合は,強制終了する. 39 | proc.kill() 40 | break 41 | await asyncio.sleep(0.1) 42 | 43 | 44 | async def execute_agent_mock( 45 | query: str, 46 | fn_report: Optional[Callable[[str], None]] = None 47 | ) -> None: 48 | await asyncio.sleep(3) 49 | if fn_report is not None: 50 | fn_report("> Final Answer: Sorry I don't understand.") 51 | 52 | 53 | if __name__ == "__main__": 54 | parser = argparse.ArgumentParser() 55 | parser.add_argument("QUERY", type=str, help="Query to be answered.") 56 | args = parser.parse_args() 57 | 58 | llm = OpenAI(temperature=0) 59 | tools = load_tools(["serpapi", "llm-math"], llm=llm) 60 | agent = initialize_agent(tools, llm, agent="zero-shot-react-description", verbose=True) 61 | 62 | agent.run(args.QUERY) 63 | -------------------------------------------------------------------------------- /src/lib/tts/tts.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import os 3 | import re 4 | import subprocess 5 | from typing import Callable, Optional 6 | 7 | from lib.utils import popen_with_callback, remove_emojis, remove_successive_spaces, remove_control_characters 8 | 9 | 10 | class SpeechModeEnum(str, Enum): 11 | """ 12 | TTS のモード 13 | """ 14 | NEURAL_JP = "neural-jp" 15 | CLASSIC_JP = "classic-jp" 16 | CLASSIC_EN = "classic-en" 17 | 18 | 19 | def speak( 20 | text: str, 21 | mode: SpeechModeEnum, 22 | callback: Optional[Callable] = None 23 | ) -> None: 24 | """ 25 | text を喋る. 26 | """ 27 | this_directory = os.path.dirname(__file__) 28 | text = convert_text_for_speech(text) 29 | if mode is SpeechModeEnum.NEURAL_JP: 30 | # 日本語を綺麗に喋る 31 | text_for_tts = convert_text_for_speech(text) 32 | proc = subprocess.run( 33 | ["sh", "./tts.sh", text_for_tts], 34 | cwd=this_directory, 35 | stdout=subprocess.PIPE 36 | ) 37 | path_to_audio_file = proc.stdout.decode("utf-8").strip() 38 | # 音声ファイルの再生開始(再生終了まで待たない.再生終了時に callback 実行) 39 | popen_with_callback( 40 | callback, 41 | ["mpg123", "-q", path_to_audio_file], 42 | cwd=this_directory 43 | ) 44 | elif mode is SpeechModeEnum.CLASSIC_JP: 45 | # 日本語を雑に喋る 46 | text_for_tts = convert_text_for_speech(text) 47 | popen_with_callback( 48 | callback, 49 | ["say", "-v", "Kyoko", text_for_tts] 50 | ) 51 | elif mode is SpeechModeEnum.CLASSIC_EN: 52 | # 英語を雑に喋る 53 | text_for_tts = convert_text_for_speech(text) 54 | popen_with_callback( 55 | callback, 56 | ["say", "-v", "Samantha", text_for_tts] 57 | ) 58 | else: 59 | raise ValueError(f"Invalid mode: {mode}") 60 | 61 | 62 | def convert_text_for_speech(text: str) -> str: 63 | """ 64 | TTS に入力するために,テキストを最適化する 65 | """ 66 | text = remove_control_characters(text) 67 | text = remove_emojis(text) 68 | text = remove_successive_spaces(text) 69 | # 空白の左側がひらがなカタカナ漢字である場合は,読点を挿入する 70 | text = re.sub(r"([ぁ-んァ-ン一-龥〜])\s", r"\1、", text) 71 | return text 72 | -------------------------------------------------------------------------------- /src/lib/youtube.py: -------------------------------------------------------------------------------- 1 | """ 2 | REF: https://qiita.com/iroiro_bot/items/ad0f3901a2336fe48e8f 3 | """ 4 | import os 5 | from typing import List, Optional, Tuple 6 | 7 | import requests 8 | from pydantic import BaseModel 9 | 10 | # 事前に取得したYouTube API key 11 | YT_API_KEY = os.getenv("YOUTUBE_API_KEY") 12 | 13 | 14 | def get_chat_id(yt_url: str) -> Optional[str]: 15 | ''' 16 | https://developers.google.com/youtube/v3/docs/videos/list?hl=ja 17 | ''' 18 | video_id = yt_url.replace('https://www.youtube.com/watch?v=', '') 19 | print('video_id : ', video_id) 20 | 21 | url = 'https://www.googleapis.com/youtube/v3/videos' 22 | params = {'key': YT_API_KEY, 'id': video_id, 'part': 'liveStreamingDetails'} 23 | data = requests.get(url, params=params).json() 24 | 25 | if data.get("status", "") == "PERMISSION_DENIED": 26 | raise RuntimeError("YouTube API failed due to permission denied.") 27 | 28 | live_streaming_details = data['items'][0]['liveStreamingDetails'] 29 | if 'activeLiveChatId' in live_streaming_details.keys(): 30 | chat_id = live_streaming_details['activeLiveChatId'] 31 | print('get_chat_id done!') 32 | else: 33 | chat_id = None 34 | print('NOT live') 35 | 36 | return chat_id 37 | 38 | 39 | class ChatLog(BaseModel): 40 | name: str 41 | message: str 42 | 43 | 44 | def get_chat(chat_id: str, page_token: Optional[str]) -> Tuple[List[ChatLog], str]: 45 | ''' 46 | https://developers.google.com/youtube/v3/live/docs/liveChatMessages/list 47 | ''' 48 | url = 'https://www.googleapis.com/youtube/v3/liveChat/messages' 49 | params = {'key': YT_API_KEY, 'liveChatId': chat_id, 'part': 'id,snippet,authorDetails'} 50 | if type(page_token) == str: 51 | params['pageToken'] = page_token 52 | 53 | data = requests.get(url, params=params).json() 54 | 55 | chat_logs: List[ChatLog] = [] 56 | try: 57 | chat_logs = [ChatLog( 58 | name=item['authorDetails']['displayName'], 59 | message=item['snippet']['displayMessage'] 60 | ) for item in data['items']] 61 | # print("start : ", data['items'][0]['snippet']['publishedAt']) 62 | # print("end : ", data['items'][-1]['snippet']['publishedAt']) 63 | except Exception: 64 | pass 65 | 66 | return chat_logs, data.get('nextPageToken', None) 67 | 68 | 69 | class ChatMonitor: 70 | def __init__(self, youtube_url: str): 71 | self.youtube_url = youtube_url 72 | chat_id = get_chat_id(youtube_url) 73 | if chat_id is None: 74 | raise ValueError("Not Live") 75 | self.chat_id = chat_id 76 | self.next_page_token: Optional[str] = None 77 | 78 | def get_recent_chats(self) -> List[ChatLog]: 79 | """ 80 | 最新のチャットの一覧を取得する(前回実行時から差分のみ) 81 | """ 82 | chat_logs, self.next_page_token = get_chat( 83 | self.chat_id, 84 | self.next_page_token 85 | ) 86 | return chat_logs 87 | 88 | 89 | class MockChatMonitor(ChatMonitor): 90 | def __init__(self): 91 | pass 92 | 93 | def get_recent_chats(self) -> List[ChatLog]: 94 | # return [ChatLog(name="test", message="わーい")] 95 | return [] 96 | -------------------------------------------------------------------------------- /src/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | バックエンドサーバー 3 | ブレインを動かしつつ,フロントエンドとソケット通信を行います. 4 | """ 5 | import asyncio 6 | import json 7 | from typing import Dict, List, Optional, cast 8 | import argparse 9 | 10 | import websockets 11 | from websockets.server import WebSocketServerProtocol 12 | from websockets.typing import Data 13 | 14 | from agent import execute_agent_mock, execute_agent_with_subprocess 15 | from lib.gptuber import Action, GPTuber 16 | from lib.chains import TVGenerator, streamer_chain 17 | from lib.utils import random_choice 18 | from lib.youtube import ChatLog, ChatMonitor, MockChatMonitor 19 | from lib.chains import NewsGenerator, CMGenerator 20 | 21 | 22 | class Server: 23 | def __init__(self): 24 | self.web_socket: Optional[WebSocketServerProtocol] = None 25 | self.chat_list: List[ChatLog] = [] 26 | 27 | async def on_message(self, websocket: WebSocketServerProtocol, path: str): 28 | 29 | self.web_socket = websocket 30 | while True: 31 | # クライアントからのメッセージを受信 32 | message = await self.web_socket.recv() 33 | print(f"Received message: {message!r}") 34 | obj = json.loads(message) 35 | if obj["type"] == "chat": 36 | self.chat_list.append(ChatLog(name="", message=obj["message"])) 37 | 38 | async def main(self): 39 | async with websockets.serve(self.on_message, "localhost", 8080): 40 | await asyncio.Future() # run forever 41 | 42 | def send_message(self, message: Data): 43 | if self.web_socket is not None: 44 | asyncio.create_task(self.web_socket.send(message)) 45 | 46 | def get_recent_chats(self) -> List[ChatLog]: 47 | """ 48 | チャット(差分のみ)を返す. 49 | """ 50 | chat_list = self.chat_list 51 | self.chat_list = [] 52 | return chat_list 53 | 54 | 55 | async def run( 56 | youtube_url: Optional[str] = None, 57 | no_llm: bool = False, 58 | no_neural_tts: bool = False, 59 | no_smart_agent: bool = False 60 | ): 61 | def _fn_streamer_llm(query: str) -> Action: 62 | pred_raw = cast(Dict[str, str], streamer_chain.predict_and_parse(input=query)) 63 | return Action(**pred_raw) 64 | 65 | def _fn_streamer_llm_mock(query: str) -> Action: 66 | return Action(text="こんにちは。今日はいい天気ですね。") 67 | 68 | chat_monitor = ChatMonitor(youtube_url) if youtube_url is not None else MockChatMonitor() 69 | 70 | def _fn_get_recent_chats() -> List[ChatLog]: 71 | chats_from_youtube = chat_monitor.get_recent_chats() 72 | chats_from_local = server.get_recent_chats() 73 | return chats_from_youtube + chats_from_local 74 | 75 | async def _fn_distract() -> str: 76 | generators: List[TVGenerator] = [ 77 | NewsGenerator(), 78 | CMGenerator() 79 | ] 80 | return await random_choice(generators).generate() 81 | 82 | async def _fn_distract_mock() -> str: 83 | return "テスト放送中" 84 | 85 | server = Server() 86 | gptuber = GPTuber( 87 | _fn_streamer_llm_mock if no_llm else _fn_streamer_llm, 88 | fn_get_recent_chats=_fn_get_recent_chats, 89 | fn_distract=_fn_distract_mock if no_llm else _fn_distract, 90 | fn_send_message=server.send_message, 91 | fn_smart_agent=execute_agent_mock if no_smart_agent else execute_agent_with_subprocess, 92 | no_neural_tts=no_neural_tts 93 | ) 94 | await asyncio.gather( 95 | server.main(), 96 | gptuber.main_loop(), 97 | gptuber.main_loop2() 98 | ) 99 | 100 | if __name__ == "__main__": 101 | parser = argparse.ArgumentParser() 102 | parser.add_argument("--youtube-url", type=str, help="YouTube Live URL, where the chat is monitored.") 103 | parser.add_argument("--no-llm", action="store_true", help="Don't use LLM.") 104 | parser.add_argument("--no-neural-tts", action="store_true", help="Don't use Neural TTS.") 105 | parser.add_argument("--no-smart-agent", action="store_true", help="Don't use Smart Agent.") 106 | args = parser.parse_args() 107 | 108 | asyncio.run(run( 109 | youtube_url=args.youtube_url, 110 | no_llm=args.no_llm, 111 | no_neural_tts=args.no_neural_tts, 112 | no_smart_agent=args.no_smart_agent 113 | )) 114 | -------------------------------------------------------------------------------- /src/lib/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | import threading 4 | import subprocess 5 | import re 6 | from typing import Callable, List, Optional, TypeVar 7 | 8 | import emoji 9 | import numpy as np 10 | import MeCab 11 | 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | def get_error_message(): 17 | exc_type, exc_value, exc_traceback = sys.exc_info() 18 | return "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) 19 | 20 | 21 | def random_choice(ary: List[T]) -> T: 22 | return np.random.choice(ary) # type: ignore 23 | 24 | 25 | def count_mora(yomi: str) -> int: 26 | if len(yomi) == 0: 27 | return 4 28 | else: 29 | return len([c for c in yomi if c not in "ャュョァィゥェォ"]) 30 | 31 | 32 | def remove_linebreaks(s: str) -> str: 33 | return s.replace("\n", " ") 34 | 35 | 36 | def remove_successive_spaces(s: str) -> str: 37 | return re.sub(r"\s+", " ", s) 38 | 39 | 40 | def remove_control_characters(s: str) -> str: 41 | # \x1b で始まり m で終わる「色指定」を除去する 42 | return re.sub(r"\x1b\[[0-9;]*m", "", s) 43 | 44 | 45 | def pick_first_row(s: str) -> str: 46 | return s.split("\n")[0] 47 | 48 | 49 | def remove_emojis(s: str, to: str = " ") -> str: 50 | # return re.sub(r'[\U0001f600-\U0001f64f\U0001f300-\U0001f5ff\U0001f680-\U0001f6ff\U0001f1e0-\U0001f1ff]', ' ', s) 51 | return emoji.replace_emoji(s, to) 52 | 53 | 54 | def extract_emojis(s: str) -> List[str]: 55 | # REF: https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python 56 | # return re.findall(r'[\U0001f600-\U0001f64f\U0001f300-\U0001f5ff\U0001f680-\U0001f6ff\U0001f1e0-\U0001f1ff]', s) 57 | return [_["emoji"] for _ in emoji.emoji_list(s)] 58 | 59 | 60 | def build_time_expression(sec: float) -> str: 61 | if sec < 60: 62 | return f"{int(sec)}秒" 63 | elif sec < 60 * 60: 64 | return f"{int(sec / 60)}分" 65 | else: 66 | return f"{int(sec / 60 / 60)}時間{int((sec / 60) % 60)}分" 67 | 68 | 69 | def popen_with_callback(on_exit: Optional[Callable], *popen_args, **popen_kwargs): 70 | """ 71 | REF: https://stackoverflow.com/questions/2581817/python-subprocess-callback-when-cmd-exits 72 | """ 73 | def run_in_thread(on_exit, popen_args, popen_kwargs): 74 | proc = subprocess.Popen(*popen_args, **popen_kwargs) 75 | proc.wait() 76 | if on_exit is not None: 77 | on_exit() 78 | return 79 | 80 | thread = threading.Thread( 81 | target=run_in_thread, 82 | args=(on_exit, popen_args, popen_kwargs) 83 | ) 84 | thread.start() 85 | return thread # returns immediately after the thread starts 86 | 87 | 88 | class WordInfo: 89 | # TODO: Pydantic 使うともっと簡潔にかけるかも 90 | def __init__(self, surface: str, feature: List[str]): 91 | self.surface = surface # 通れ 92 | self.hinshi = feature[0] # 動詞 93 | self.hinshi_detail1 = feature[1] # 自立 94 | self.hinshi_detail2 = feature[2] # * 95 | self.hinshi_detail3 = feature[3] # * 96 | self.katsuyougata = feature[4] # 一段 97 | self.katsuyoukei = feature[5] # 連用形 98 | self.genkei = feature[6] if feature[6] != "*" else surface # 通れる 99 | self.yomi = feature[7] if len(feature) > 7 else surface # トオレ 100 | self.hatsuon = feature[8] if len(feature) > 8 else surface # トーレ 101 | 102 | def __repr__(self): 103 | return f"WordInfo({repr(self.surface)}, {repr(self.get_feature_list())})" 104 | 105 | def __eq__(self, other): 106 | return self.surface == other.surface and self.get_feature_list() == other.get_feature_list() 107 | 108 | def get_feature_list(self): 109 | return [ 110 | self.hinshi, 111 | self.hinshi_detail1, 112 | self.hinshi_detail2, 113 | self.hinshi_detail3, 114 | self.katsuyougata, 115 | self.katsuyoukei, 116 | self.genkei, 117 | self.yomi, 118 | self.hatsuon 119 | ] 120 | 121 | 122 | class MeCabParser: 123 | def __init__(self): 124 | self.mt = MeCab.Tagger("") 125 | self.mt.parse("") # バグ対処のため最初に一度行う必要がある 126 | 127 | def parse(self, text: str) -> List[WordInfo]: 128 | """ 129 | mecab で parse を行う. 130 | ---- 131 | Args: 132 | text (str): 133 | 分かち書きを行いたい文字列 134 | Returns: 135 | (list of WordInfo): 136 | 単語情報のリスト 137 | """ 138 | assert isinstance(text, str), "text must be str" # parseToNode に str 以外が入ると Kernel Death が生じて厄介のため 139 | tokens = [] 140 | node = self.mt.parseToNode(text) 141 | while node: 142 | tokens.append(WordInfo(node.surface, node.feature.split(","))) 143 | node = node.next 144 | 145 | # 空白抜け補正処理 146 | offset = 0 147 | for token in tokens[1:-1]: 148 | index = text.find(token.surface, offset) 149 | if index < 0: 150 | print("WARNING: 空白抜け補正処理に失敗しました.") 151 | index = 0 152 | token.surface = text[offset:index] + token.surface 153 | offset += len(token.surface) 154 | 155 | return tokens 156 | 157 | 158 | mecab_parser = MeCabParser() 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPTuber 2 | 3 | 大規模言語モデルが YouTuber をやります. 4 | GPU や TPU は不要です(代わりに,いくつかの強力な API を利用します). 5 | 6 | # Demo 7 | 8 | [![YouTube](https://img.youtube.com/vi/xgUuw4k8wwY/0.jpg)](https://www.youtube.com/watch?v=xgUuw4k8wwY&t=3060s) 9 | 10 | # Setup 11 | 12 | Mac で動かすことを想定しています. 13 | 14 | - 以下のインストールが必要です. 15 | 16 | - MeCab 17 | - 字幕を文節ごとに表示する処理で使用します. 18 | - Python >= 3.8.1 19 | - LangChain の最新版を利用したいためです. 20 | - requirements.txt に記載された Python パッケージ 21 | - `pip install -r requirements.txt` で入ります. 22 | - gcloud コマンド 23 | - YouTuber の音声合成に Google の Text-to-Speech API を使用しますが,その際に使用します. 24 | - mpg123 25 | - YouTuber の音声ファイルを再生する時に使用します. 26 | - say コマンド 27 | - YouTuber が使用するスマートスピーカーの発話に使用します. 28 | 29 | - 以下の API を利用可能にしておく必要があります. 30 | 31 | - OpenAI 32 | - 大規模言語モデルを利用します. 33 | - 2022 年 12 月現在,アカウント登録すると,3 ヶ月間限定で使用可能な 18 ドルの無料クレジットが付与されます.[REF.](https://openai.com/api/pricing/) 34 | - Google Text-to-Speech API 35 | - YouTuber の音声合成に使用します. 36 | - 2022 年 12 月現在,1 ヶ月あたり 100 万文字まで無料で,それ以上は 1 文字あたり 0.000016 ドルです.[REF.](https://cloud.google.com/text-to-speech/pricing) 37 | - Google YouTube Data API 38 | - YouTube Live のチャット情報をリアルタイムで取得するために使用します. 39 | - 2022 年 12 月現在,無料ですが,quota の上限はあります.[REF.](https://developers.google.com/youtube/v3/getting-started) 40 | - serpapi 41 | - YouTuber がスマートスピーカーに質問した時に,スマートスピーカー(=LangChain の Agent)が内部で tool として Search(ウェブ検索)を利用しますが,その際に必要です. 42 | - 2022 年 12 月現在,アカウント登録すると,Free Plan の場合は 1 ヶ月あたり 100 回の検索まで無料です.[REF.](https://serpapi.com/pricing) 43 | 44 | - 以下の環境変数を設定しておく必要があります. 45 | 46 | ```sh 47 | # OpenAI の API により大規模言語モデルを利用するために API キーが必要 48 | export OPENAI_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 49 | # Google の Text-to-Speech API により音声合成を行うために必要な認証情報が記載された json ファイルへのパス 50 | export GOOGLE_APPLICATION_CREDENTIALS="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.json" 51 | # Google の YouTube Live のチャット情報の取得用 API に必要な API キー 52 | export YOUTUBE_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 53 | # スマートスピーカーが内部で利用する tool の 1 つである serpapi に必要な API キー 54 | export SERPAPI_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 55 | ``` 56 | 57 | # Usage 58 | 59 | (A)(B)(C) の 3 パターンに分けて記述します. 60 | 61 | ## (A): 認証情報が全て揃った状態で,手元で動かす(配信はしない)場合 62 | 63 | 以下の手順で起動します. 64 | 65 | - `./public/index.html` をブラウザで開きます. 66 | - 配信用のスクリーンが表示されます. 67 | - `cd ./src; python server.py` によりバックエンドを起動します. 68 | - ブラウザで開いた配信用のスクリーンのページの「バックエンドとの接続状況」のマルが灰色から緑色になれば OK です.ならない場合は,`server.py` 側のエラーを確認してください. 69 | - 10-20 秒ほど待っていると,YouTuber が喋り始めます.また,喋りの内容が配信用のスクリーンに表示されます. 70 | 71 | 配信用のスクリーンのすぐ下にあるフォームから,YouTuber にチャットを送ることができます(タイムラグが多少ありますが). 72 | 73 | ## (B): (A) に加えて,実際に YouTube で配信する場合 74 | 75 | 以下の手順で起動します.なお,(A) に加えて,YouTube Live の配信が可能な状態になっていること,および, 76 | 配信ソフト(OBS 等),またデスクトップ音声をキャプチャ可能な仮想マイクデバイス(Blackhole 等)が必要です. 77 | 78 | - `./public/index.html` をブラウザで開きます. 79 | - 配信用のスクリーンが表示されます. 80 | - デスクトップ音声が,デスクトップ音声をキャプチャ可能な仮想マイクデバイス(Blackhole 等)に流れるように設定します(OS 側の設定). 81 | - YouTube Live の配信開始画面を開きます. 82 | - 配信ソフト(OBS 等)を起動します. 83 | - YouTube Live と接続するための各種情報を設定します. 84 | - 映像ソースとして,上記の配信用のスクリーンを指定します. 85 | - 音声ソースとして,上記の仮想マイクデバイスを指定します. 86 | - 配信を開始します. 87 | - `cd ./src; python server.py --youtube-url "https://www.youtube.com/watch?v=xxxxxxxxxxx"` によりバックエンドを起動します. 88 | - `--youtube-url` には,YouTube Live の配信を視聴するための URL を指定します. 89 | - ブラウザで開いた配信用のスクリーンのページの「バックエンドとの接続状況」のマルが灰色から緑色になれば OK です.ならない場合は,`server.py` 側のエラーを確認してください. 90 | - 10-20 秒ほど待っていると,YouTuber が喋り始めます.また,喋りの内容が配信用のスクリーンに表示されます. 91 | 92 | YouTube Live のチャット欄から YouTuber にチャットを送ることができます. 93 | また,(A) と同様,配信用スクリーンのすぐ下にあるフォームからもチャットを送ることができます. 94 | いずれもタイムラグは多少あります. 95 | 96 | ## (C): 認証情報が不足している状態でとりあえず動かしたい場合 97 | 98 | 基本は (A)(B) の手順ですが,足りない認証情報に応じて,以下のようにします. 99 | 100 | - `server.py` の起動時オプションを変更します. 101 | - OpenAI の認証情報がない場合 102 | - 大規模言語モデルを動かせません.`--no-llm` オプションを追加すれば起動できますが,YouTuber は `こんにちは。今日はいい天気ですね。` としか喋りません. 103 | - Google Text-to-Speech API の認証情報がない場合 104 | - YouTuber が綺麗な日本語を話せません.`--no-neural-tts` オプションを追加すれば起動できますが,YouTuber の発話音声のクオリティは下がります. 105 | - Google YouTube Live API の認証情報がない場合 106 | - YouTube Live 上のチャットを取得できません.`--youtube-url` オプションを省略すれば起動はできますが,YouTube Live 上のチャット欄に投稿した内容が YouTuber に届きません. 107 | - serpapi の認証情報がない場合 108 | - YouTuber がスマートスピーカーを利用できません.`--no-smart-agent` オプションを追加すれば起動はできますが,スマートスピーカーは `> Final Answer: Sorry I don't understand.` としか返答しません. 109 | - なお,そもそも YouTuber がスマートスピーカーを起動しようとするのをやめたい場合は,プロンプト自体を編集してください. 110 | 111 | # 仕様 112 | 113 | - YouTuber の発言は,大規模言語モデルを用いて生成されます. 114 | - 生成された文章の中に絵文字が含まれる場合,変換テーブルによりエモートに変換され,配信スクリーンの画像が動的に切り替わります. 115 | - プロンプトの前半部分に,YouTuber の基本的な設定が記載されています(絵文字を多く含む返答をする旨もここに記載があります). 116 | - プロンプトの後半には「ここまでの会話の要約」が挿入されます.この要約内容も大規模言語モデルにより裏側で毎ターン生成され更新されます. 117 | - プロンプトのさらに続きには「直近の視聴者からのチャット内容」が挿入されます. 118 | - チャットがしばらくの間一件もない場合,時折「TV が何か言っている:......」という一節がプロンプトに挿入されます(TV の放送内容も大規模言語モデルにより生成されます).これは,YouTuber の話す内容がネタ切れにならないようにするための仕組みです. 119 | - Google Home からの回答(後述)が得られた直後の場合は,「Google Home の回答:......」という一節がプロンプトに挿入されます. 120 | - YouTuber は Google Home を所有している設定に(プロンプトの前半部分の記載により)なっており,時に `OK Google,` で始まる発話をすることがあります.この時,Google Home への指示は LangChain の Agent への入力にそのままなります. 121 | - LangChain の Agent (`ZeroShotAgent`) は,与えられた問題を解決するため,自己思考・行動選択のループを行い(これも大規模言語モデルへの適切なプロンプトによって行われます),回答が得られたと納得した段階で最終回答を返します.挙動の例は[LangChain 公式の Docs](https://langchain.readthedocs.io/en/latest/getting_started/agents.html)をご覧ください. 122 | - そのログ出力内容(Thought, Action, Observation 等)は,そのまま Google Home の声として発話され,配信スクリーンに字幕も表示されます. 123 | 124 | # 設定変更したい場合 125 | 126 | - キャラ設定,状況設定など 127 | - 大規模言語モデルを LangChain を用いて呼び出すコードは `./src/lib/chains.py` にまとまっています.この中のプロンプトを適宜編集することで,キャラ設定や状況設定などを変更できます. 128 | - 画像 129 | - 出力されたテキスト中の絵文字をエモートに変換する仕様となっています.変換テーブルは `./src/lib/emoji-to-emote.csv` にあります.エモート画像は `./public/img/streamer/` にあります.これらを適宜変更してください.アニメーション gif の使用を推奨します. 130 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | Screen 11 | 101 | 155 | 156 | 157 | 158 |
159 |
160 | 161 |
162 | ・・・ 163 |
164 |
165 | チャットください 166 |
167 |
168 |
169 | バックエンドとの接続状況: 170 |
171 |
172 | 173 | (Shift+Enter) 174 |
175 |
176 | 177 | 178 | -------------------------------------------------------------------------------- /src/lib/chains.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import List, Optional, Dict, cast 4 | 5 | from langchain import LLMChain, OpenAI, ConversationChain, PromptTemplate 6 | from langchain.chains.conversation.memory import ConversationSummaryMemory 7 | from langchain.prompts.base import BaseOutputParser 8 | 9 | from lib.utils import pick_first_row, random_choice, remove_linebreaks 10 | 11 | 12 | class OutputParserForConversation(BaseOutputParser): 13 | """ 14 | 配信者用の chain からの出力をパースする. 15 | """ 16 | def parse(self, output: str) -> Dict[str, Optional[str]]: # type: ignore 17 | # NOTE: BaseOutputParser では Return value は Dict[str, str] だが,ここでは Optional[str] にしている. 18 | output_cleansed = remove_linebreaks(pick_first_row(output.strip())).strip() \ 19 | .removeprefix("「") \ 20 | .removesuffix("」") \ 21 | .removeprefix("\"") \ 22 | .removesuffix("\"") \ 23 | .removeprefix("'") \ 24 | .removesuffix("'") 25 | query_to_google_home: Optional[str] = None 26 | _ = re.search(r"OK Google[,,、](.+?($|[??!!。]))", output_cleansed) 27 | if _ is not None: 28 | query_to_google_home = _.groups()[0].strip() 29 | if query_to_google_home is not None and len(query_to_google_home) > 40: # 長すぎる場合は抽出失敗してそうなので 30 | query_to_google_home = None 31 | 32 | return { 33 | "text": output_cleansed, 34 | "query_to_google_home": query_to_google_home 35 | } 36 | 37 | 38 | # 配信者用の chain 39 | streamer_chain = ConversationChain( 40 | llm=OpenAI( 41 | # stop=["\n"], 42 | temperature=0.7, 43 | frequency_penalty=1.0, 44 | presence_penalty=1.0 45 | ), # 「2手以上先の予測」を切り落とすために stop に "\n" を入れていたが,最初に "\n" が出力される問題が今度は出てきたので,stop を空にした.代わりに output_parser 側で対応する.ただし ConversationMemory の方には output_parser の処理は反映されていない.まあいいか. 46 | memory=ConversationSummaryMemory( 47 | llm=OpenAI(), 48 | prompt=PromptTemplate( 49 | input_variables=['summary', 'new_lines'], 50 | template='Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new summary.\n\nEXAMPLE\nCurrent summary:\nOne of audiences asks what the streamer thinks of artificial intelligence. The streamer thinks artificial intelligence is a force for good.\n\nNew lines of conversation:\nAudience: Why do you think artificial intelligence is a force for good?\nStreamer: Because artificial intelligence will help humans reach their full potential.\n\nNew summary:\nOne audience asks what the AI thinks of artificial intelligence. The streamer thinks artificial intelligence is a force for good because it will help humans reach their full potential.\nEND OF EXAMPLE\n\nCurrent summary:\n{summary}\n\nNew lines of conversation:\n{new_lines}\n\nNew summary:' 51 | ) # NOTE: 逐次要約用プロンプト 52 | ), 53 | verbose=True, 54 | prompt=PromptTemplate( 55 | template="I want you to act as a YouTube Streamer. Audiences write in the chat, and you will reply to all of them **in Japanese**. The reply should be no more than 80 letters. The Streamer has the following characters:\n- You are cute and fancy female cat.\n- Your name is \"タマ\" and you call yourself \"わたし\".\n- You have Google Home, and when audiences chat hard questions, you like to ask it about that questions, with saying \"OK Google\".\n- You are talkative and provides lots of specific details from its context.\n- You use at least 10 emojis in each reply.\n\nCurrent conversation:\n{history}\n{input}Streamer (You):", 56 | input_variables=["history", "input"], 57 | output_parser=OutputParserForConversation() 58 | ) # NOTE: 状況設定用プロンプト 59 | ) 60 | 61 | 62 | # chain の出力が何かを列挙する感じのものである場合に,それをパースするためのクラス 63 | class OutputParserForListedAnswers(BaseOutputParser): 64 | def __init__(self, regex, *args, **kwargs): 65 | super().__init__(*args, **kwargs) 66 | self.regex = regex 67 | 68 | def parse(self, output: str) -> List[str]: 69 | return self.regex.findall(output) 70 | 71 | 72 | # 「カテゴリ: str」を受け取って「カテゴリの具体例: List[str]」を返す chain(.__call__ ではなく .predict_and_parse(input=) を使用してください) 73 | concretizer_chain = LLMChain( 74 | llm=OpenAI( 75 | stop=["\n"], 76 | temperature=0.7, 77 | frequency_penalty=1.0, 78 | presence_penalty=1.0 79 | ), # stop は「2手以上先の予測」を切り落とすため 80 | verbose=True, 81 | prompt=PromptTemplate( 82 | input_variables=["category"], 83 | template="The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.\n\nHuman: Hello, who are you?\nAI: I am an AI created by OpenAI. How can I help you today?\nHuman:「{category}」の具体例を5個挙げてください。それぞれの回答は「」で囲ってください。AI:", 84 | output_parser=OutputParserForListedAnswers(regex=re.compile(r"「(.*?)」")) 85 | ) # NOTE: プロンプトは https://beta.openai.com/examples/default-chat を参考にしました 86 | ) 87 | 88 | 89 | # 「商品ジャンル: str」を受け取って「CMテキスト: str」を作る chai 90 | cm_chain = LLMChain( 91 | llm=OpenAI( 92 | stop=["\n"], 93 | temperature=0.7, 94 | frequency_penalty=1.0, 95 | presence_penalty=1.0 96 | ), 97 | verbose=True, 98 | prompt=PromptTemplate( 99 | input_variables=["genre"], 100 | template="The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.\n\nHuman: Hello, who are you?\nAI: I am an AI created by OpenAI. How can I help you today?\nHuman: I want you to act as a radio broadcasting commercials **in Japanese**. I will type a genre of the product and you will reply the talk script of the commercial. You should include a specific product name in your script. I want you to only reply with what I hear from the radio, and nothing else. do not write explanations. my first command is {genre}\nAI:", 101 | ) # NOTE: プロンプトは https://beta.openai.com/examples/default-chat と https://github.com/f/awesome-chatgpt-prompts を参考にしました 102 | ) 103 | 104 | 105 | # 「ニュースジャンル: str」を受け取って「ニューステキスト: str」を作る chain 106 | news_chain = LLMChain( 107 | llm=OpenAI( 108 | stop=["\n"], 109 | temperature=0.7, 110 | frequency_penalty=1.0, 111 | presence_penalty=1.0 112 | ), 113 | verbose=True, 114 | prompt=PromptTemplate( 115 | input_variables=["genre"], 116 | template="The following is a conversation with an AI assistant. The assistant is helpful, creative, clever, and very friendly.\n\nHuman: Hello, who are you?\nAI: I am an AI created by OpenAI. How can I help you today?\nHuman: I want you to act as a radio broadcasting news **in Japanese**. I will type a genre of the news and you will reply the talk script of the news. Do not use anonymized names (e.g. XXX) in the script. I want you to only reply with what I hear from the radio, and nothing else. do not write explanations. my first command is {genre}\nAI:", 117 | ) # NOTE: プロンプトは https://beta.openai.com/examples/default-chat と https://github.com/f/awesome-chatgpt-prompts を参考にしました 118 | ) 119 | 120 | 121 | # TV放送の内容を生成するクラス 122 | class TVGenerator: 123 | async def generate(self) -> str: 124 | raise NotImplementedError 125 | 126 | 127 | # CMの内容を生成するクラス 128 | class CMGenerator(TVGenerator): 129 | def __init__(self, categories: List[str] = ["ペット用品", "ガジェット", "旅行先", "健康法"]): 130 | self.categories = categories 131 | 132 | async def generate(self) -> str: 133 | category = random_choice(self.categories) 134 | genres = cast(List[str], concretizer_chain.predict_and_parse(category=category)) 135 | await asyncio.sleep(0.1) 136 | genre = random_choice(genres) 137 | cm = cm_chain.predict(genre=genre) 138 | return cm 139 | 140 | 141 | # ニュースの内容を生成するクラス 142 | class NewsGenerator(TVGenerator): 143 | def __init__(self, categories: List[str] = ["ニュースジャンル"]): 144 | self.categories = categories 145 | 146 | async def generate(self) -> str: 147 | category = random_choice(self.categories) 148 | genres = cast(List[str], concretizer_chain.predict_and_parse(category=category)) 149 | await asyncio.sleep(0.1) 150 | genre = random_choice(genres) 151 | cm = news_chain.predict(genre=genre) 152 | return cm 153 | -------------------------------------------------------------------------------- /src/lib/gptuber.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import sys 4 | import time 5 | from typing import Awaitable, Callable, List, Optional, Tuple 6 | 7 | import numpy as np 8 | from pydantic import BaseModel, Field 9 | 10 | from agent import FnSmartAgent 11 | from lib.tts.tts import SpeechModeEnum, speak 12 | from lib.utils import WordInfo, build_time_expression, count_mora, get_error_message, mecab_parser, remove_emojis, remove_linebreaks 13 | from lib.youtube import ChatLog 14 | from lib.emotes import determine_emote_from_text 15 | 16 | 17 | class Action(BaseModel): 18 | """ 19 | 行動を表すクラス 20 | """ 21 | text: str = Field(..., description="発話するテキスト") 22 | query_to_google_home: Optional[str] = Field(None, description="Google Home に対しての質問文(あれば)") 23 | by: Optional[str] = Field(None, description="発話者") 24 | 25 | 26 | class GPTuber: 27 | def __init__( 28 | self, 29 | fn_streamer_llm: Callable[[str], Action], 30 | fn_get_recent_chats: Optional[Callable[[], List[ChatLog]]] = None, 31 | fn_distract: Optional[Callable[[], Awaitable[str]]] = None, 32 | fn_send_message: Optional[Callable[[str], None]] = None, 33 | fn_smart_agent: Optional[FnSmartAgent] = None, 34 | no_neural_tts: bool = False 35 | ): 36 | """ 37 | 「配信者」のクラス 38 | ---- 39 | Args: 40 | fn_streamer_llm: 大規模言語モデルを用いた,YouTuberの行動を生成する関数.この関数は「直近の出来事を表すレポート」を引数にとり,行動を返す必要がある. 41 | 直近の出来事を表すレポートは,基本的に以下のようなフォーマットである. 42 | ``` 43 | Audience: こんにちは 44 | Audience: おはよう 45 | Audience: おはようございます 46 | ``` 47 | これに加えて,Google Home からの返答がある場合は,以下のような行が追加される. 48 | ``` 49 | (Google Home の答え: ............ ) 50 | ``` 51 | なお,視聴者からのチャットが一件もなかった場合は,Audience の行が無い代わりに,以下のような行が追加される. 52 | ``` 53 | (視聴者のチャットが無く ... 分経過) 54 | ``` 55 | さらに,一定時間視聴者からのチャットがなかった場合には,TV の放送内容を表す以下のような行が追加される場合がある. 56 | ``` 57 | (TV: 「........」) 58 | ``` 59 | fn_get_recent_chats: 直近のチャットを取得する関数.この関数は,前回呼ばれたときからの差分のチャットの一覧を返す必要がある. 60 | fn_distract: YouTuber の話題を変えるために置かれた「TVが喋っている内容(トークスクリプト)」を生成する関数. 61 | fn_send_message: フロントエンド側にメッセージ送信する(字幕やエモートの表示指示)ための関数 62 | fn_smart_agent: Google Home を発動させるための関数.この関数は,クエリ(str) に加えて,ログ内容をコールバックするための関数 (Callable[[str], None]) を引数にとる. 63 | ログ内容およびログ回数は任意であるが,Google Home からの最終返答を YouTuber にフィードバックするには,"Final Answer: " という文字列を含むログ内容を 64 | 一度コールバックする必要がある. 65 | no_neural_tts: True の場合,Neural TTS を使用せず,標準の TTS を使用する(品質は下がる). 66 | """ 67 | self.fn_streamer_llm = fn_streamer_llm 68 | self.fn_get_recent_chats = fn_get_recent_chats 69 | self.fn_distract = fn_distract 70 | self.fn_send_message = fn_send_message 71 | self.fn_smart_agent = fn_smart_agent 72 | self.no_neural_tts = no_neural_tts 73 | self.main_loop_wait_sec = 10.0 74 | self.actions_reserved: List[Action] = [] 75 | self.is_now_acting: bool = False 76 | self.last_chat_time: float = time.time() 77 | self.last_non_boring_time: float = time.time() 78 | self.boring_patience_sec = 120.0 79 | self.final_answer_from_google_home: Optional[str] = None 80 | 81 | async def main_loop(self): 82 | """ 83 | 行動生成のためのメインループ 84 | """ 85 | await asyncio.sleep(5) 86 | while True: 87 | if len(self.actions_reserved) < 3: 88 | current_time = time.time() 89 | # 最新のコメントを取得 90 | new_chat_logs = self.fn_get_recent_chats() if self.fn_get_recent_chats is not None else [] 91 | # レポート(直近の動き)を作成 92 | if len(new_chat_logs) > 0: 93 | report = "".join( 94 | [f"Audience: {remove_linebreaks(log.message)[:256]}" + "\n" for log in new_chat_logs] 95 | ) 96 | self.last_chat_time = current_time 97 | self.last_non_boring_time = current_time 98 | else: 99 | elapsed_sec = current_time - self.last_chat_time 100 | report = f"(視聴者のチャットが無く{build_time_expression(elapsed_sec)}経過)" + "\n" 101 | # 暇時間が一定に達した場合,TVの情報が割り込む 102 | if self.fn_distract is not None and current_time - self.last_non_boring_time > self.boring_patience_sec: 103 | report += f"(TV: 「...{remove_linebreaks(await self.fn_distract())}」)" + "\n" 104 | self.last_non_boring_time = current_time 105 | # Google Home が答えを返してきた場合,その情報が割り込む 106 | if self.final_answer_from_google_home is not None: 107 | report += f"(Google Home の答え: {remove_linebreaks(self.final_answer_from_google_home)})" + "\n" 108 | self.final_answer_from_google_home = None 109 | try: 110 | # LLM に聞く(メモリー付きのChainの場合は,内部的にメモリーも更新される) 111 | action = self.fn_streamer_llm(report) 112 | print(f"{action=}") 113 | action.by = "streamer" 114 | # 行動の予約 115 | self.reserve_action(action) 116 | except Exception: 117 | # 503 が多分多い 118 | print(get_error_message(), file=sys.stderr) 119 | # 待機 120 | await asyncio.sleep(self.main_loop_wait_sec) 121 | 122 | async def main_loop2(self): 123 | """ 124 | 行動消化のためのメインループ 125 | """ 126 | await asyncio.sleep(5) 127 | while True: 128 | self.check_acting_and_act() 129 | await asyncio.sleep(1) 130 | 131 | def reserve_action(self, action: Action): 132 | """ 133 | 未完了の行動がある場合は,行動を予約する.ない場合は,直ちに行動する. 134 | """ 135 | self.actions_reserved.append(action) 136 | self.check_acting_and_act() 137 | 138 | def check_acting_and_act(self): 139 | """ 140 | 行動中でなければ,予約された行動を実行する. 141 | """ 142 | if not self.is_now_acting: 143 | if len(self.actions_reserved) > 0: 144 | # a = self.actions_reserved.pop(0) 145 | # 先頭を取り出すが,agent の行動がある場合は優先して取り出したほうが良さそう. 146 | idx_agent = [i for i, a in enumerate(self.actions_reserved) if a.by == "agent"] 147 | if len(idx_agent) > 0: 148 | a = self.actions_reserved.pop(idx_agent[0]) 149 | else: 150 | a = self.actions_reserved.pop(0) 151 | self.act_now(a) 152 | 153 | def on_finish_action(self): 154 | """ 155 | 行動終了時に呼び出される関数.呼び出される設定は行動開始時になされる. 156 | """ 157 | self.is_now_acting = False 158 | 159 | def act_now(self, action: Action): 160 | """ 161 | 直ちに行動を実行する. 162 | """ 163 | self.is_now_acting = True 164 | if action.by == "streamer": 165 | # if action.emote is not None: 166 | # asyncio.create_task(self.emote_now(action.emote)) 167 | if action.text is not None: 168 | asyncio.create_task(self.speak_now(action.text, by=action.by)) 169 | if action.query_to_google_home is not None: 170 | asyncio.create_task(self.query_to_google_home_now(action.query_to_google_home)) 171 | elif action.by == "agent": 172 | if action.text is not None: 173 | asyncio.create_task(self.speak_now(action.text, by=action.by)) 174 | 175 | async def speak_now(self, text: str, by: str): 176 | """ 177 | 直ちに喋る 178 | """ 179 | if by == "streamer": 180 | speak( 181 | text, 182 | mode=SpeechModeEnum.CLASSIC_JP if self.no_neural_tts else SpeechModeEnum.NEURAL_JP, 183 | callback=self.on_finish_action 184 | ) 185 | elif by == "agent": 186 | speak( 187 | text, 188 | mode=SpeechModeEnum.CLASSIC_EN, 189 | callback=self.on_finish_action 190 | ) 191 | else: 192 | raise ValueError(f"Invalid by: {by}") 193 | 194 | if self.fn_send_message is not None: 195 | # 字幕の表示指示 196 | timeline = generate_subtitle_timeline( 197 | text, 198 | flg_split=by == "streamer", 199 | prefix="" if by == "streamer" else "(Google Home) " 200 | ) 201 | self.fn_send_message( 202 | json.dumps({ 203 | "type": "subtitle", 204 | "timeline": timeline 205 | }, ensure_ascii=False) 206 | ) 207 | 208 | async def emote_streamer_now(self, kind: str): 209 | """ 210 | 直ちに表情を変える(現在不使用) 211 | """ 212 | if self.fn_send_message is not None: 213 | self.fn_send_message( 214 | json.dumps({ 215 | "type": "emote", 216 | "kind": kind 217 | }, ensure_ascii=False) 218 | ) 219 | 220 | async def query_to_google_home_now(self, query: str): 221 | """ 222 | 直ちに Google Home に問い合わせる.実際には,追加のアクションを予約する. 223 | """ 224 | def _fn_report(text: str): 225 | text = text.strip() 226 | if text != "": 227 | self.reserve_action(Action( 228 | by="agent", 229 | text=text 230 | )) 231 | if "Final Answer: " in text: 232 | self.final_answer_from_google_home = text.split("Final Answer: ")[1] 233 | 234 | if self.fn_smart_agent is not None: 235 | print(f"fn_smart_agent is started. {query=}") 236 | await self.fn_smart_agent(query, fn_report=_fn_report) 237 | print(f"fn_smart_agent is finished. {query=}") 238 | 239 | 240 | def generate_subtitle_timeline( 241 | text: str, 242 | flg_split: bool = True, 243 | prefix: str = "" 244 | ) -> List[Tuple[float, str, Optional[str]]]: 245 | """発話内容テキストから字幕表示指示を生成する. 246 | ---- 247 | Args: 248 | text (str): 発話内容テキスト 249 | flg_split (bool, optional): 文節ごとに字幕を表示するか. Defaults to True. 250 | prefix (str, optional): 全ての発話の先頭に付与する文字列(例えば,発話者を表す目的で使用可能である). Defaults to "". 251 | 252 | Returns: 253 | List[Tuple[float, str, Optional[str]]]: (時刻(sec), 字幕表示内容, エモート変更指示) のリスト. 254 | """ 255 | words = mecab_parser.parse(text)[1:-1] 256 | chunks: List[Tuple[int, str]] = [] 257 | buffer = "" 258 | mora_count = 0 259 | for i, word in enumerate(words): 260 | buffer += word.surface 261 | mora_count += count_mora(word.yomi) 262 | # きりの良いところでバッファクリアする 263 | if i == len(words) - 1 or (flg_split and is_likely_to_split(word, words[i + 1])): 264 | chunks.append((mora_count, buffer)) # まとめて出す方式に変更 265 | buffer = "" 266 | mora_count = 0 267 | 268 | # モーラカウントを適切な秒数に変換する 269 | coef = 0.14 # 1モーラあたりの秒数 270 | timeline = list(zip( 271 | coef * np.cumsum([0] + [chunk[0] for chunk in chunks]), 272 | [remove_emojis(chunk[1], "") for chunk in chunks] + [""], # 最後は字幕消す 273 | [determine_emote_from_text(chunk[1]) for chunk in chunks] + [None] # 最後は表情指示無し 274 | )) 275 | 276 | # prefix の付与(最後以外) 277 | timeline = [( 278 | t, 279 | (prefix if i + 1 < len(timeline) else "") + text, 280 | emote 281 | ) for i, (t, text, emote) in enumerate(timeline)] 282 | 283 | return timeline 284 | 285 | 286 | def is_likely_to_split(word1: WordInfo, word2: WordInfo) -> bool: 287 | """ 288 | 与えられた単語のペアが,文節の区切りとして適切かどうかを判定する. 289 | """ 290 | brachet_start = "「『【(〈《〔[{〘〖〝〟‘“([{" 291 | if word1.hinshi in ["記号"] and word1.surface not in brachet_start and word2.hinshi not in ["記号"]: 292 | return True 293 | if word1.hinshi in ["助詞", "助動詞"] and word2.hinshi in ["名詞", "動詞", "形容詞", "副詞", "連体詞", "形容動詞"] and word2.surface not in "ー♪": 294 | return True 295 | return False 296 | --------------------------------------------------------------------------------