diff --git a/.gitattributes b/.gitattributes index 486a232..8b4a606 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.zip filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text diff --git a/LICENSE_ffmpeg b/LICENSE_ffmpeg new file mode 100644 index 0000000..65c5ca8 --- /dev/null +++ b/LICENSE_ffmpeg @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md index 82f1799..6b7948a 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,64 @@ # ZundamonGPTonYouTube +English | [日本語](README2.md)

Intelligent Zundamon replies YouTube chat with GPT brain.

## First of all -- This project is now Japanese only but I have a plan to open my source codes, then you'll be able to customize language. -- If you are not good at English, please use [Google Transrate](https://translate.google.com/?sjid=2238627840605957328-AP&sl=auto&op=websites) -

+- This Application is Japanese only since it's depend on Japanese voice engine "VOICEVOX", but You can cutomize by modifying MIT liccenced source codes. ## This application works on - Windows OS (tested on Windows 10) - .Net Framework v.4 (tested on v4.7.2) - the machine on which installed [VOICEVOX](https://voicevox.hiroshiba.jp/) (tested on v.0.14.6) -Core Module is implemented by Python, so it can adapt to other OS or voice generators. Please wait for opening source codes. + Core Module is implemented by Python, so it can adapt to other OS or voice generators.
+ Only avatar image viewer heavily depends on Windows since its codes are written in C#. I have a plan to replace the codes to python in order to be available on other several platforms.

## This application can - automatically pick up messages from YouTube chat and make Zundamon speak the GPT answer of those messages out.
Thogh non Japanese messages are given, Zundamon answers in Japanese. - display all comments of YouTube chat, picked up comments, answers of picked up comments. -- display Zundamon portrait with transparent background.
- -![](ReadMeParts/zundamon_full.png) +- display Zundamon portrait with transparent background. +- You can use not only Zundamon voice and image but also other ones.
+[![GttsAIStreamer sample](ReadMeParts/zundamon_thumbnail.png)](https://www.youtube.com/embed/7GgssTTo2-c) ## Usage - Install [VOICEVOX](https://voicevox.hiroshiba.jp/) - Get OpenAI api-key. Please refer [here(English)](https://www.howtogeek.com/885918/how-to-get-an-openai-api-key/) or [here(Japanese)](https://laboratory.kazuuu.net/how-to-get-an-openai-api-key/) -- click [here](https://github.com/GeneralYadoc/ZundamonGPTonYouTube/releases/download/v1.0.0/ZundamonGPTonYouTube.zip) to download. -- Unzip Downloaded "ZundamonGPTonYouTube.zip" file. -- Open "ZundamonGPTonYouTube" and double click ZundamonGPTonYouTube_SampleUI.exe. +- if you want to launch from .exe file. + - click [here](https://github.com/GeneralYadoc/ZundamonGPTonYouTube/releases) to download newest version. + - Unzip Downloaded "ZundamonGPTonYouTube.zip" file. + - Open "ZundamonGPTonYouTube" and double click ZundamonGPTonYouTube.exe. +- if you want to launch from source codes. + - Install ffmpeg.
+ For Linux: Execute following command. + ```ffmpeg installation for Linux + $ sudo apt-get install ffmpeg + ``` + + For Windows: Access [here](https://github.com/BtbN/FFmpeg-Builds/releases), download '*-win64-gpl.zip', extract the zip and move three exe files (ffmpeg.exe, ffprobe.exe, ffplay.exe) to the folder where you'll execute the sample or added path.
+
+ For Mac: Access [here](https://brew.sh/), copy installation command to your terminal and push Enter key, and execute following command. + ``` + brew install ffmpeg + ``` + - Clone repository.
+ ```clone + git clone https://github.com/GeneralYadoc/ZundamonGPTonYouTube.git + ``` + - Move to ZundamonGPTonYouTube directory + ```mv + mv ZundamonGPTonYouTube. + ``` + - Install the application. + ```install + pip install . + ``` + - Start the application. + ``` + python3 ZundamonGPTonYouTube.py + ``` - Check Video ID of target YouTube stream.
![](ReadMeParts/video_id.png) - Fill in the Video ID brank of start form. (use Ctrl+V to paste) @@ -37,42 +67,54 @@ Thogh non Japanese messages are given, Zundamon answers in Japanese. ![](ReadMeParts/start_form.png) ### Notice -- OpenAI api-key and Video ID is recorded in "variable_cache.yaml" and You can skip either or both from the 2nd time. -- Please be aware of treating "variable_cache.yaml" in order to avoid leaking OpenAI api-key. +- OpenAI api key and Video ID is recorded in "variable_cache.yaml" and You can skip either or both from the 2nd time. +- Please be aware of treating "variable_cache.yaml" in order to avoid leaking OpenAI api key.

## GUI is consisted of -### Zundamon portrait window (main window) -- You can swich opaque or transparent background by double clicking Zundamon. -- You can resize Zundamon in opaque background mode, please elase background after adjusting Zundamon size if you want. -- Minimizinq window also is available in opaque backgroune mode. -- You can exit the application by closing this window. window "x" button of TopRight also is enable in opaque background mode.
+### Main window +- You can change visibility of chat window by pressing "ちゃっと" button, asking window by pressing "しつもん" button, answering window by pressing "こたえ" button, portrait window by pressing "立ち絵" button. +- You can change voice volume by using slide bar which is at the bottom of the window. +- Also you can change voice volume by putting value on text box at the just right of the slide bar and press enter key. +- You can exit the application by closing this window. window "x" button of TopRight.
+ ![](ReadMeParts/main_window.png) + +### Portrait window +- You can display a portrait of avatar which you like by specifying the path in setting file. +- You can switch opaque or transparent background by double clicking the avatar. +- You can resize the avatar in opaque background mode, please erase background after adjusting avatar size if you want. +- Minimizinq window also is available in opaque backgroune mode.
+- The Application keeps running even if this window is closed, so you can close this window if unnecessary.
![](ReadMeParts/zundamon_opaque_transparent.png) ### YouTube chat monitor window - Almost all messages are shown in this window. -- Messages contain only stamps are ignored. -- Some messages which exist in 1msec polling gap may lost. +- Messages contain only emoticons are ignored. +- Some messages which exist in polling gap may lost. +- You can switch visibility of window frame by double clicking message area of the window. +- Please turn on the frame when resizing the window. - The Application keeps running even if this window is closed, so you can close this window if unnecessary.
![](ReadMeParts/monitor_window.png) ### Window for asking -- All picked up messages which will be answered by Zundamon are shown in this window. +- All picked up messages which will be answered by ChatAI are shown in this window. +- You can switch visibility of window frame by double clicking message area of the window. +- Please turn on the frame when resizing the window. - The Application keeps running even if this window is closed, so you can close this window if unnecessary.
![](ReadMeParts/ask_window.png) ### Window for answering -- Zundamon answers for picked up messages are shown in this window. +- ChatAI answers for picked up messages are shown in this window. +- You can switch visibility of window frame by double clicking message area of the window. +- Please turn on the frame when resizing the window. - The Application keeps running even if this window is closed, so you can close this window if unnecessary.
![](ReadMeParts/answer_window.png)
### Notice - The following window is VOICEVOX which is external application.
- It's necessary for generating Zundamon voices, so please don't close the window.
+ It's necessary for generating Zundamon voices, so please don't close the window. (please minimize if you want to hide it.)
![](ReadMeParts/voicevox_window.png)
- -

# Settings @@ -83,41 +125,61 @@ You can customize the application with "setting.yaml" which is exist in the same voicevox_path: '' # チャット欄ウインドウの設定 -monitor_window_visible: 'True' # 'True' or 'False' -display_user_name_on_monitor_window: 'False' # 'True' or 'False' -monitor_window_title: 'ちゃっとらん' -monitor_window_size: '350x754' -monitor_window_color: '#ffffff' -monitor_font_color: '#000000' -monitor_font_size: '10' -monitor_font_type: 'Courier' +display_user_name_on_chat_window: 'True' +chat_window_title: 'ちゃっとらん' +chat_window_size: '350x754' +chat_window_padx : '9' +chat_window_pady : '9' +chat_window_color: '#ffffff' +chat_font_color: '#000000' +chat_font_size: '10' +chat_font_type: 'Courier' +chat_rendering_method: 'normal' # 質問ウインドウの設定 -display_user_name_on_ask_window: 'False' # 'True' or 'False' +display_user_name_on_ask_window: 'False' ask_window_title: 'ぐみんのしつもん' ask_window_size: '500x250' +ask_window_padx : '9' +ask_window_pady : '9' ask_window_color: '#354c87' ask_font_color: '#ffe4fb' ask_font_size: '12' ask_font_type: 'Courier' +ask_rendering_method: 'refresh' # 回答ウインドウの設定 answer_window_title: 'てんさいずんだもんのこたえ' answer_window_size: '500x450' +answer_window_padx : '9' +answer_window_pady : '9' answer_window_color: '#ffe4e0' answer_font_color: '#004cF7' answer_font_size: '13' answer_font_type: 'Helvetica' -volume: 100 # 0 - 1000 +answer_rendering_method: 'incremental' + +# AIの設定 model: 'gpt-3.5-turbo' -max_tokens_per_request: 256 -ask_interval_sec: 5.0 +max_tokens_per_request: 1024 +ask_interval_sec: 20.0 + +# 回答キャラクターの設定 +speaker_type: 1 +volume: 100 +system_role: 'あなたはユーザーとの会話を楽しく盛り上げるために存在する、日本語話者の愉快なアシスタントです。' +image_file: zundamon.gif ``` -- voicevox_path can remain blank if VOICEVOX has been installed to default path. -- You can change AI model by modifying "model" value. +- "voicevox_path" can remain blank if VOICEVOX has been installed to default path. +- You can change AI model by changing "model" value. +- You can change voice actor by changing "speaker_type" value. +- You can change Avatar image by changing "image_file" path. +

+# Licence +- The lisence type of this application is MIT, so you can customize freely. +- the lisence type of ffmeg executable files included in release package is LGPL.

- # Links -- [Pixiv page of 坂本アヒル](https://www.pixiv.net/users/12147115)   I obtained Zundamon portrait from here. -- [pytchat](https://github.com/taizan-hokuto/pytchat)   Python library for fetching youtube live chat. +- [Pixiv page of 坂本アヒル](https://www.pixiv.net/users/12147115)   I obtained static Zundamon portrait which is the material of the gif animation from here. +- [ChatAIStreamer](https://github.com/taizan-hokuto/pytchat)   Python library for getting ChatGPT voiced answer of YouTube chat stream. diff --git a/README2.md b/README2.md new file mode 100644 index 0000000..ef33c72 --- /dev/null +++ b/README2.md @@ -0,0 +1,187 @@ +# ZundamonGPTonYouTube +[English](README.md) | 日本語

+かしこいずんだもんがYouTubeのチャット欄にGPTずのーでこたえてくれるプログラムです。 +

+ +## はじめに +- このアプリケーションは日本製のボイスジェネレータ VOICEVOX に依存しているため日本語限定ですが、ソースコードをMITライセンスで公開しているので、自由にカスタマイズできます。 + +## 動作環境 +- Windows OS (10 にて動作確認済) +- .Net Framework v.4 (v4.7.2 にて動作確認済) +- [VOICEVOX](https://voicevox.hiroshiba.jp/) のインストールが必要 (v.0.14.6 にて動作確認済) + + コアモジュールはPythonで作成されているため、その他の複数のOSやボイスジェネレータに適応可能です。
+ キャラクター表示ウィンドウだけはC#で作られているためWindowsに強く依存しています。キャラクター表示ウィンドウについてもPythonへの移植を計画しています。 +

+ +## 機能 +- YouTubeチャットを自動で拾い、それに対するChatGPTの回答をずんだもんの声で読み上げます。
+日本語以外のメッセージに対しても日本語で返答します。 +- YouTubeのすべてのチャットメッセージ、ピックアップされたメッセージ、ピックアップされたメッセージへの回答コメントを表示できます。 +- ずんだもんの立ち絵を背景透過で表示できます。 +- 声や立ち絵はずんだもん以外に変更することも可能です。
+[![GttsAIStreamer sample](ReadMeParts/zundamon_thumbnail.png)](https://www.youtube.com/embed/7GgssTTo2-c) + +## 使い方 +- [VOICEVOX](https://voicevox.hiroshiba.jp/) をリンク先からインストール。 +- OpenAIのapi-keyを取得。 取得方法は [ここ(英語)](https://www.howtogeek.com/885918/how-to-get-an-openai-api-key/) か [ここ(日本語)](https://laboratory.kazuuu.net/how-to-get-an-openai-api-key/) を参照してください。 +- .exe ファイルから実行する場合 + - [ここ](https://github.com/GeneralYadoc/ZundamonGPTonYouTube/releases) をクリックして最新版をダウンロード。 + - "ZundamonGPTonYouTube.zip" ファイルを解凍。 + - "ZundamonGPTonYouTube" フォルダを開き、 ZundamonGPTonYouTube.exe ファイルをダブルクリック。 +- ソースコードから実行する場合 + - ffmpegをインストール。
+ Linux の場合: 以下のコマンドを実行。 + ```ffmpeg installation for Linux + $ sudo apt-get install ffmpeg + ``` + + Windows の場合:[ここ](https://github.com/BtbN/FFmpeg-Builds/releases) にアクセスして、'\*-win64-gpl.zip' をダウンロード。
+ zip ファイルを解凍し、中の3つの exe ファイル(ffmpeg.exe, ffprobe.exe, ffplay.exe)を、ZundamonGPTonYouTube 実行フォルダ、もしくはパスの通ったフォルダに置く。
+
+ Mac の場合:[ここ](https://brew.sh/)にアクセスし、記載されているコマンドを端末に貼り付けて Enter を押下。
+ 以下のコマンドを実行。 + ``` + brew install ffmpeg + ``` + - リポジトリをクローン。
+ ```clone + git clone https://github.com/GeneralYadoc/ZundamonGPTonYouTube.git + ``` + - ZundamonGPTonYouTube ディレクトリに移動。 + ```mv + mv ZundamonGPTonYouTube. + ``` + - アプリケーションをインストール。 + ```install + pip install . + ``` + - アプリケーションを開始。 + ``` + python3 ZundamonGPTonYouTube.py + ``` +- 対象 YouTube ストリームの Video ID をチェック。
+ ![](ReadMeParts/video_id.png) +- スタートウィンドウの Video ID 欄に YouTube ストリームの Video ID を記入。 (Ctrl+V で貼り付けできます) +- スタートウィンドウの API Key 欄に OpenAI の api key を記入。 (Ctrl+V で貼り付けできます) +- "すたーと" ボタンをクリック。
+ ![](ReadMeParts/start_form.png) + +### 注意 +- OpenAI の apikey と Video ID は "variable_cache.yaml" という名前のファイルに記録され、2回目以降の入力を省略できます。 +- OpenAI の api key が流出しないよう、"variable_cache.yaml" の扱いには気をつけてください。 +

+ +## 画面構成 + +### メインウィンドウ +- "ちゃっと" ボタンを押すことでチャット欄ウィンドウの、"しつもん" ボタンを押すことで質問ウィンドウの、"こたえ" ボタンを押すことで回答ウィンドウの、"立ち絵" ボタンを押すことで立ち絵ウィンドウの表示・非表示を切り替えることができます。 +- ウィンドウ下部のスライドバーで音声ボリュームを調整できます。 +- スライドバーの右隣にあるテキストボックスに値を記入し、エンターキーを押下する方法でも、音量変更が可能です。 +- ウィンドウ右上の "x" ボタンをクリックすることで、アプリケーションを終了できます。
+ ![](ReadMeParts/main_window.png) + +### 立ち絵ウィンドウ +- 設定ファイルに画像ファイルパスを指定することで好きなイラストを表示できます。 +- 立ち絵をダブルクリックすることで、背景の透過・不透過を切り替えることができます。 +- キャラクターのサイズは背景不透明モードのときに変更することができます。キャラクターのサイズを調整した後に背景を消してください。 +- ウィンドウの最小化についても、実行できるのは背景不透明モードのときです。
+- このウィンドウを閉じてもアプリケーションは動作し続けますので、不要な場合は閉じてください。
+ ![](ReadMeParts/zundamon_opaque_transparent.png) + +### YouTubu チャットウィンドウ +- ほぼすべての YouTube チャットコメントがここに表示されます。 +- 絵文字のみで構成されているコメントは無視されます。 +- ポーリング間隔の隙間に入ったコメントは漏れることがあります。 +- メッセージ領域をダブルクリックすることで、ウィンドウ枠の表示・非表示を切り替えることができます。 +- ウィンドウをリサイズするときはウィンドウ枠を表示させてください。 +- このウィンドウを閉じてもアプリケーションは動作し続けますので、不要な場合は閉じてください。
+ ![](ReadMeParts/monitor_window.png) + +### 質問ウィンドウ +- ChatAI に回答させるためにピックアップしたすべてのコメントがここに表示されます。 +- メッセージ領域をダブルクリックすることで、ウィンドウ枠の表示・非表示を切り替えることができます。 +- ウィンドウをリサイズするときはウィンドウ枠を表示させてください。 +- このウィンドウを閉じてもアプリケーションは動作し続けますので、不要な場合は閉じてください。
+ ![](ReadMeParts/ask_window.png) + +### 回答ウィンドウ +- ピックアップされたメッセージに対する ChatAI の回答がここに表示されます。 +- メッセージ領域をダブルクリックすることで、ウィンドウ枠の表示・非表示を切り替えることができます。 +- ウィンドウをリサイズするときはウィンドウ枠を表示させてください。 +- このウィンドウを閉じてもアプリケーションは動作し続けますので、不要な場合は閉じてください。
+ ![](ReadMeParts/answer_window.png)
+ +### 注意 + - 以下のウィンドウは、外部アプリケーション "VoiceVox" のものです。
+ ずんだもんの音声再生に必要ですので、閉じないでください。(隠したい場合は最小化してください) + ![](ReadMeParts/voicevox_window.png)
+

+ +# 設定 + +アプリケーションの .exe ファイルと同じ階層にある設定ファイル "setting.yaml" を用いて、設定を変更することが出来ます。 +```setting.yaml +# VoiceVoxの設定 +voicevox_path: '' + +# チャット欄ウィンドウの設定 +display_user_name_on_chat_window: 'True' +chat_window_title: 'ちゃっとらん' +chat_window_size: '350x754' +chat_window_padx : '9' +chat_window_pady : '9' +chat_window_color: '#ffffff' +chat_font_color: '#000000' +chat_font_size: '10' +chat_font_type: 'Courier' +chat_rendering_method: 'normal' + +# 質問ウィンドウの設定 +display_user_name_on_ask_window: 'False' +ask_window_title: 'ぐみんのしつもん' +ask_window_size: '500x250' +ask_window_padx : '9' +ask_window_pady : '9' +ask_window_color: '#354c87' +ask_font_color: '#ffe4fb' +ask_font_size: '12' +ask_font_type: 'Courier' +ask_rendering_method: 'refresh' + +# 回答ウィンドウの設定 +answer_window_title: 'てんさいずんだもんのこたえ' +answer_window_size: '500x450' +answer_window_padx : '9' +answer_window_pady : '9' +answer_window_color: '#ffe4e0' +answer_font_color: '#004cF7' +answer_font_size: '13' +answer_font_type: 'Helvetica' +answer_rendering_method: 'incremental' + +# AIの設定 +model: 'gpt-3.5-turbo' +max_tokens_per_request: 1024 +ask_interval_sec: 20.0 + +# 回答キャラクターの設定 +speaker_type: 1 +volume: 100 +system_role: 'あなたはユーザーとの会話を楽しく盛り上げるために存在する、日本語話者の愉快なアシスタントです。' +image_file: zundamon.gif +``` + +- VOICEVOX を標準の場所にインストールしている場合は、"voicevox_path" を空欄のままにしておくことができます。 +- "model" の値を変更することで、AI のモデルを変更できます。 +- "speaker_type" の値を変更することで、回答の声を変更できます。 +- "image_file" に記載のパスを変更することで、キャラクター画像を変更できます。 +

+# ライセンス +- このアプリケーションはMITライセンスですので、自由にカスタマイズ可能です。 +- リリースパッケージに含まれる ffmpeg の実行ファイル一式は LGPL ライセンスです。 +

+# リンク集 +- [Pixiv page of 坂本アヒル](https://www.pixiv.net/users/12147115)   GIF アニメーションに使用したずんだもん立ち絵静止画の入手元。 +- [ChatAIStreamer](https://github.com/taizan-hokuto/pytchat)   YouTube チャットに対するChatGPTの音声付き回答を取得することができる Python ライブラリ。 diff --git a/ReadMeParts/main_window.png b/ReadMeParts/main_window.png new file mode 100644 index 0000000..3ad9413 Binary files /dev/null and b/ReadMeParts/main_window.png differ diff --git a/ReadMeParts/raw_button.png b/ReadMeParts/raw_button.png deleted file mode 100644 index 515501d..0000000 Binary files a/ReadMeParts/raw_button.png and /dev/null differ diff --git a/ReadMeParts/start_form.png b/ReadMeParts/start_form.png index a0cb174..718c340 100644 Binary files a/ReadMeParts/start_form.png and b/ReadMeParts/start_form.png differ diff --git a/ReadMeParts/zundamon_full.png b/ReadMeParts/zundamon_full.png deleted file mode 100644 index eb24c95..0000000 Binary files a/ReadMeParts/zundamon_full.png and /dev/null differ diff --git a/ReadMeParts/zundamon_opaque_transparent.png b/ReadMeParts/zundamon_opaque_transparent.png index 402b870..05930d3 100644 Binary files a/ReadMeParts/zundamon_opaque_transparent.png and b/ReadMeParts/zundamon_opaque_transparent.png differ diff --git a/ReadMeParts/zundamon_thumbnail.png b/ReadMeParts/zundamon_thumbnail.png new file mode 100644 index 0000000..f49329b Binary files /dev/null and b/ReadMeParts/zundamon_thumbnail.png differ diff --git a/Release/ZundamonGPTonYouTube.zip b/Release/ZundamonGPTonYouTube.zip deleted file mode 100644 index 9fb1e7d..0000000 --- a/Release/ZundamonGPTonYouTube.zip +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3130cffe72784160ea344e16acd219906247c6b59423400f0f5de7a6ef5eeaba -size 111480607 diff --git a/TransparentViewer.exe b/TransparentViewer.exe new file mode 100644 index 0000000..eba3146 Binary files /dev/null and b/TransparentViewer.exe differ diff --git a/TransparentViewer.exe.config b/TransparentViewer.exe.config new file mode 100644 index 0000000..5754728 --- /dev/null +++ b/TransparentViewer.exe.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ZundamonGPTonYouTube.py b/ZundamonGPTonYouTube.py new file mode 100644 index 0000000..8f8d7d5 --- /dev/null +++ b/ZundamonGPTonYouTube.py @@ -0,0 +1,7 @@ +# To use this script, please install VOICEVOX and ffmpeg. +import sys +import os +import ZundamonAIStreamerUI as zui + +ui = zui.ZundamonAIStreamerUI(workspace=os.path.abspath(os.path.dirname(sys.argv[0]))) +ui.mainloop() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4cdda36 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +chatai-streamer==2.0.3 +PyYAML==6.0 +googletranslate-python==1.0.3 +deep-translator==1.11.1 +requests==2.29.0 +pydub==0.25.1 +PySimpleGUI==4.60.5 diff --git a/setting.yaml b/setting.yaml new file mode 100644 index 0000000..8033dd9 --- /dev/null +++ b/setting.yaml @@ -0,0 +1,48 @@ +# VoiceVox̐ݒ +voicevox_path: '' + +# `bgEBhE̐ݒ +display_user_name_on_chat_window: 'True' +chat_window_title: 'Ƃ' +chat_window_size: '350x754' +chat_window_padx : '9' +chat_window_pady : '9' +chat_window_color: '#ffffff' +chat_font_color: '#000000' +chat_font_size: '10' +chat_font_type: 'Courier' +chat_rendering_method: 'normal' + +# EBhE̐ݒ +display_user_name_on_ask_window: 'False' +ask_window_title: '݂̂‚' +ask_window_size: '500x250' +ask_window_padx : '9' +ask_window_pady : '9' +ask_window_color: '#354c87' +ask_font_color: '#ffe4fb' +ask_font_size: '12' +ask_font_type: 'Courier' +ask_rendering_method: 'refresh' + +# 񓚃EBhE̐ݒ +answer_window_title: 'Ă񂳂񂾂̂' +answer_window_size: '500x450' +answer_window_padx : '9' +answer_window_pady : '9' +answer_window_color: '#ffe4e0' +answer_font_color: '#004cF7' +answer_font_size: '13' +answer_font_type: 'Helvetica' +answer_rendering_method: 'incremental' + +# AI̐ݒ +model: 'gpt-3.5-turbo' +max_tokens_per_request: 1024 +ask_interval_sec: 20.0 + +# 񓚃LN^[̐ݒ +speaker_type: 1 +volume: 100 +system_role: 'Ȃ̓[U[Ƃ̉byグ邽߂ɑ݂A{b҂̖ȃAVX^głB' +image_file: zundamon.gif diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..35ec97f --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +from glob import glob +from os.path import basename +from os.path import splitext + +from setuptools import setup +from setuptools import find_packages + + +def _requires_from_file(filename): + return open(filename).read().splitlines() + + +setup( + name="zundamonai-streamer", + version="2.0.0", + license="MIT", + description="Zundamon with ChatGPT brain answers aloud YouTube chat messages.", + author="General Yadoc", + author_email="133023047+GeneralYadoc@users.noreply.github.com", + classifiers=[ + 'Development Status :: 4 - Beta', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'License :: OSI Approved :: MIT License', + ], + url="https://github.com/GeneralYadoc/ChatAIStreamer", + packages=find_packages("src"), + package_dir={"": "src"}, + py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + zip_safe=False, + install_requires=_requires_from_file('requirements.txt'), + setup_requires=["pytest-runner"], + tests_require=["pytest", "pytest-cov"] +) diff --git a/src/ZundamonAIStreamer.py b/src/ZundamonAIStreamer.py new file mode 100755 index 0000000..7dbb2f6 --- /dev/null +++ b/src/ZundamonAIStreamer.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass +from deep_translator import GoogleTranslator +import threading +import subprocess +import ctypes +import os +import time +import math +import glob +import tempfile +import json +import requests +import pydub +import pydub.playback as pb +import ChatAIStreamer as casr + +ChatAIStreamer = casr.ChatAIStreamer +streamParams = casr.streamParams +userMessage = casr.userMessage +aiParams = casr.aiParams + +TMPFILE_POSTFIX = "_ZundamonAIStreamer" + +# Data type of voice used by ZundamonAIStreamer +@dataclass +class voiceData: + content: bytes=None + +# Concrete VoiceGanarator of ZundamonAIStreamer +# 'generate' is overridden. +class ZundamonGenerator(casr.voiceGenerator): + def __init__(self, speaker=1, max_retry=20): + self.__speaker = speaker + self.__max_retry = max_retry + super(ZundamonGenerator, self).__init__() + @property + def speaker(self): + return self.__speaker + @speaker.setter + def speaker(self, speaker): + self.__speaker = speaker + def __generate(self, text): + query_payload = {"text": text, "speaker": self.__speaker} + for i in range(self.__max_retry): + r = requests.post( "http://localhost:50021/audio_query", + params=query_payload, timeout=(10.0, 300.0) ) + if r.status_code == 200: + query_data = r.json() + break + time.sleep(1) + + if r.status_code != 200: + return r + + synth_payload = {"speaker": self.__speaker} + for i in range(self.__max_retry): + r = requests.post( "http://localhost:50021/synthesis", params=synth_payload, + data=json.dumps(query_data), timeout=(10.0, 300.0) ) + if r.status_code == 200: + break + time.sleep(1) + + return r + def generate(self, text): + voice = None + # Translate into Japanese. + try: + text_ja = GoogleTranslator(target='ja').translate(text=text) + except: + return text, voice + + r = self.__generate(text_ja) + + if r.status_code == 200: + # Pack in viceData format. + voice = voiceData(content=bytes(r.content)) + + # return voice with translated text. + return text_ja, voice + +# Extend streamerParams and params to hold voiceGenerator instance. +# voice_generator is defaultly intialized with English voiceGenerator. +@dataclass +class streamerParams(casr.streamerParams): + voice_generator : casr.voiceGenerator = ZundamonGenerator() + voicevox_path : str = os.getenv("LOCALAPPDATA") + "/" + "Programs/VOICEVOX/VOICEVOX.exe" +@dataclass +class params(casr.params): + streamer_params : streamerParams = streamerParams() + +class InterruptPlaying(Exception): + pass + +# VoicePlayer class which plays the voice generated by ZundamonGenerator. +class VoicePlayer(threading.Thread): + def __init__(self, voice, get_volume_cb): + self.__voice = voice + self.__get_volume_cb = get_volume_cb + super(VoicePlayer, self).__init__(daemon=True) + + def run(self): + if self.__voice: + self.__play(self.__voice) + + def __play(self, voice): + with tempfile.NamedTemporaryFile() as file: + file_path = file.name + TMPFILE_POSTFIX + with open(file_path, "wb") as file: + file.write(voice.content) + + segment = pydub.AudioSegment.from_wav(file_path) + segment = segment[10:] + self.__interrupted = False + remain_sec = total_sec = segment.duration_seconds + playng_first_section = True + while remain_sec > 0.0: + volume = self.__get_volume_cb() + if playng_first_section: + playng_first_section = False + prev_volume = volume + tmp_segment = edit_segment(segment, volume , total_sec - remain_sec) + play_thread = threading.Thread(target=self.play_interruptible, args=[tmp_segment,], daemon=True) + play_thread.start() + elif volume != prev_volume: + self.__interrupted = True + play_thread.join() + self.__interrupted = False + tmp_segment = edit_segment(segment, volume , total_sec - remain_sec) + play_thread = threading.Thread(target=self.play_interruptible, args=[tmp_segment,], daemon=True) + play_thread.start() + remain_sec = remain_sec - 0.1 + prev_volume = volume + time.sleep(1) + remain_sec = remain_sec - 1.0 + play_thread.join() + + def __interrupt(self, length_sec, th): + if hasattr(th, '_thread_id'): + th_id = th._thread_id + else: + for id, thread in threading._active.items(): + if thread is th: + th_id = id + for i in range(math.ceil(length_sec / 0.01)): + if self.__interrupted: + ctypes.pythonapi.PyThreadState_SetAsyncExc(th_id, ctypes.py_object(InterruptPlaying)) + break + time.sleep(0.01) + + def __pb_play(self, segment): + try: + pb.play(segment) + except InterruptPlaying: + pass + + def play_interruptible(self, segment): + length_sec = segment.duration_seconds + + th = threading.Thread(target=self.__pb_play, args=[segment,], daemon=True) + th_interrupt = threading.Thread(target=self.__interrupt, args=[length_sec, th]) + th.start() + th_interrupt.start() + th_interrupt.join() + th.join() + +def edit_segment(segment, volume, start_sec): + delta = pydub.utils.ratio_to_db(volume / 100.) + segment = segment[start_sec * 1000:] + delta + return segment + +# ZundamonAIStreamer as inheritence from ChatAIStreamer +class ZundamonAIStreamer(casr.ChatAIStreamer): + def __init__(self, params): + # Remove garbage of temporary files. + with tempfile.NamedTemporaryFile() as file: + tmp_dir_path = os.path.dirname(file.name) + for file_path in glob.glob(f"{tmp_dir_path}/*{TMPFILE_POSTFIX}*"): + os.remove(file_path) + + program_path = params.streamer_params.voicevox_path + if not program_path or program_path == "" or not os.path.exists(program_path): + program_path = os.getenv("LOCALAPPDATA") + "/" + "Programs/VOICEVOX/VOICEVOX.exe" + if os.path.exists(program_path): + subprocess.Popen(program_path) + time.sleep(1) + + super(ZundamonAIStreamer, self).__init__(params) diff --git a/src/ZundamonAIStreamerManager.py b/src/ZundamonAIStreamerManager.py new file mode 100644 index 0000000..dcaf146 --- /dev/null +++ b/src/ZundamonAIStreamerManager.py @@ -0,0 +1,126 @@ +# To use this class, please install ffmpeg. +from dataclasses import dataclass +from typing import Callable +import time +import math +import threading +import queue +import ZundamonAIStreamer as zasr + +streamParams = zasr.streamParams +aiParams = zasr.aiParams +streamerParams = zasr.streamerParams + +@dataclass +class params(zasr.params): + send_message_cb: Callable[[str, str, bool], None] = None + speaker_type: int = 1 + volume: int = 100 + +@dataclass +class voicedAnswer(): + user_message: any = "" + completion: any = "" + voice: any = None + +class ZundamonAIStreamerManager(threading.Thread): + # Customized sleep for making available of running flag interruption. + def __interruptibleSleep(self, time_sec): + counter = math.floor(time_sec / 0.10) + frac = time_sec - (counter * 0.10) + for i in range(counter): + if not self.__running: + break + time.sleep(0.10) + if not self.__running: + return + time.sleep(frac) + + def __getItemCB(self, c): + self.__send_message_cb(key="chat", name=c.author.name, message=c.message) + pass + + def __get_volume_cb(self): + return self.__volume + + # callback for getting answer of ChatGPT + # The voice generated by ZundamonGenerator is given. + def __speak(self): + while self.__running: + while self.__running and self.__voiced_answers_queue.empty(): + self.__interruptibleSleep(0.1) + voiced_answer = self.__voiced_answers_queue.get() + user_message = voiced_answer.user_message + completion = voiced_answer.completion + voice = voiced_answer.voice + + self.__send_message_cb(key="ask", name=user_message.extern.author.name, message=user_message.message) + self.__interruptibleSleep(1) + # Play the voice by VoicePlayer of ZundamonAIStreamer + self.__player = None + self.__player = zasr.VoicePlayer(voice, get_volume_cb=self.__get_volume_cb) + self.__interruptibleSleep(1) + self.__player.start() + self.__send_message_cb(key="answer", message=completion.choices[0]["message"]["content"]) + + # Wait finishing Playng the voice. + self.__player.join() + del self.__player + self.__player = None + self.__interruptibleSleep(0.1) + + # callback for getting answer of ChatGPT + # The voice generated by ZundamonGenerator is given. + def __answerWithVoiceCB(self, user_message, completion, voice): + while self.__running and self.__voiced_answers_queue.full(): + self.__interruptibleSleep(0.1) + self.__voiced_answers_queue.put(voicedAnswer(user_message=user_message, completion=completion, voice=voice)) + + @property + def volume(self): + return self.__volume + @volume.setter + def volume(self, volume): + self.__volume = volume + + def __init__(self, params): + self.__send_message_cb = params.send_message_cb + self.__player = None + self.__volume = params.volume + + # Set params of getting messages from stream source. + params.stream_params.get_item_cb=self.__getItemCB + + # Create ZundamonVoiceGenerator + params.streamer_params.voice_generator=zasr.ZundamonGenerator(speaker=params.speaker_type) + params.streamer_params.answer_with_voice_cb=self.__answerWithVoiceCB + + # Create ZundamonAIStreamer instance. + # 'voice_generator=' is omittable for English generator. + self.ai_streamer =zasr.ZundamonAIStreamer(params) + + self.__voiced_answers_queue = queue.Queue(2) + self.__speaker_thread = threading.Thread(target=self.__speak, daemon=True) + + super(ZundamonAIStreamerManager, self).__init__(daemon=True) + + + def run(self): + self.__running = True + + # Wake up internal thread to get chat messages from stream and play VoiceVox voices of reading ChatGPT answers aloud. + self.ai_streamer.start() + + self.__speaker_thread.start() + + def disconnect(self): + self.__running=False + + # Finish generating gTTS voices. + # Internal thread will stop soon. + self.ai_streamer.disconnect() + + # terminating internal thread. + self.ai_streamer.join() + + diff --git a/src/ZundamonAIStreamerUI.py b/src/ZundamonAIStreamerUI.py new file mode 100644 index 0000000..5c7d315 --- /dev/null +++ b/src/ZundamonAIStreamerUI.py @@ -0,0 +1,409 @@ +from dataclasses import dataclass +import ZundamonAIStreamerManager as zm +import sys +import os +import math +import time +import yaml +import queue +import subprocess +import tkinter as tk +import tkinter.font as font + +@dataclass +class messageSlot(): + message: str + refresh: bool + +class ZundamonAIStreamerUI: + def __createStartWindow(self): + self.__root.geometry('336x120') + self.__root.title('なんでもこたえてくれるずんだもん') + + self.__clearStartWindow() + self.__widgits_start["video_id_label"] = tk.Label(text='Video ID') + self.__widgits_start["video_id_label"].place(x=30, y=15) + self.__widgits_start["video_id_entry"] = tk.Entry(width=32) + self.__widgits_start["video_id_entry"].place(x=90, y=15) + + self.__widgits_start["api_key_label"] = tk.Label(text='API Key') + self.__widgits_start["api_key_label"].place(x=30, y=48) + self.__widgits_start["api_key_entry"] = tk.Entry(width=32) + self.__widgits_start["api_key_entry"].place(x=90, y=48) + + self.__widgits_start["button"] = tk.Button(self.__root, text="すたーと", command=self.__start) + self.__widgits_start["button"].place(x=143, y=80) + + def __clearStartWindow(self): + for widgit in self.__widgits_start.values(): + widgit.destroy() + self.__widgits_start.clear() + + def __createMainWindow(self): + self.__root.geometry('336x120') + self.__root.title('めいんういんどう') + self.__root.iconbitmap(default = self.__icon) + + self.__clearMainWindow() + self.__widgits_main["buttonChat"] = tk.Button(self.__root, text="ちゃっと", width="6", command=lambda:self.__changeVisible("chat")) + self.__widgits_main["buttonChat"].place(x=30, y=20) + + self.__widgits_main["buttonAsk"] = tk.Button(self.__root, text="しつもん", width="6", command=lambda:self.__changeVisible("ask")) + self.__widgits_main["buttonAsk"].place(x=104, y=20) + + self.__widgits_main["buttonAnswer"] = tk.Button(self.__root, text="こたえ", width="6", command=lambda:self.__changeVisible("answer")) + self.__widgits_main["buttonAnswer"].place(x=178, y=20) + + self.__widgits_main["buttonPortrait"] = tk.Button(self.__root, text="立ち絵", width="6", command=self.__changeVisiblePortrait) + self.__widgits_main["buttonPortrait"].place(x=252, y=20) + + self.__widgits_main["volumeLabel"] = tk.Label(text='volume') + self.__widgits_main["volumeLabel"].place(x=27, y=70) + self.__widgits_main["scaleVolume"] = tk.Scale( self.__root, + variable = tk.DoubleVar(), + command = self.__changeVolume, + orient=tk.HORIZONTAL, + sliderlength = 20, + length = 200, + from_ = 0, + to = 500, + resolution=5, + tickinterval=250 ) + + self.__widgits_main["scaleVolume"].set(self.__initial_volume) + self.__widgits_main["scaleVolume"].place(x=72, y=50) + + self.__widgits_main["volumeEntry"] = tk.Entry(width=3, justify=tk.RIGHT) + self.__widgits_main["volumeEntry"].bind(sequence="", func=self.__scaleVolume) + self.__widgits_main["volumeEntry"].place(x=282, y=70) + + self.__receiveMessage() + + def __clearMainWindow(self): + for widgit in self.__widgits_main.values(): + widgit.destroy() + self.__widgits_main.clear() + + def __createMessageWindow(self, key): + window = tk.Toplevel() + window.title(self.__sub_window_settings[key]["title"]) + window.geometry(self.__sub_window_settings[key]["window_size"]) + window.protocol("WM_DELETE_WINDOW", lambda:self.__changeVisible(key)) + + frame = tk.Frame(window) + frame.pack(fill = tk.BOTH) + + text = tk.Text( frame, + bg=self.__sub_window_settings[key]["window_color"], + fg=self.__sub_window_settings[key]["font_color"], + selectbackground=self.__sub_window_settings[key]["window_color"], + selectforeground=self.__sub_window_settings[key]["font_color"], + width=800, + height=100, + bd="0", + padx=self.__sub_window_settings[key]["window_padx"], + pady=self.__sub_window_settings[key]["window_pady"], + font=font.Font( size=self.__sub_window_settings[key]["font_size"], + family=self.__sub_window_settings[key]["font_type"], + weight="bold" ), + state="disabled" ) + + + self.__sub_windows[key] = { + "visible" : False, + "body" : window, + "text" : text, + "mouse_position" : [0, 0], + "message_queue" : queue.Queue(1000) + } + self.__sub_windows[key]["body"].bind(sequence="", func=lambda event:self.__clickWindow(key=key, event=event)) + self.__sub_windows[key]["body"].bind(sequence="", func=lambda event:self.__moveWindow(key=key, event=event)) + self.__sub_windows[key]["body"].bind(sequence="", func=lambda event:self.__doubleclickWindow(key=key, event=event)) + self.__sub_windows[key]["text"].pack() + self.__sub_windows[key]["body"].withdraw() + + def __interruptibleSleep(self, time_sec): + counter = math.floor(time_sec / 0.10) + frac = time_sec - (counter * 0.10) + for i in range(counter): + if not self.__running: + break + time.sleep(0.10) + if not self.__running: + return + time.sleep(frac) + + def __sendMessageCore(self, key, message, refresh): + message_queue = self.__sub_windows[key]["message_queue"] + if not message_queue.full(): + slot = messageSlot(message=message, refresh=refresh) + message_queue.put(slot) + + def __sendMessage(self, key, name="", message=""): + rendering_method = self.__sub_window_settings[key]["rendering_method"] + display_name = self.__sub_window_settings[key]["display_name"] + + if display_name == 'True': + message = f"[{name}] {message}" + + if rendering_method == "incremental": + for i in range(len(message)): + refresh = True if i == 0 else False + self.__sendMessageCore(key, message[i : i+1], refresh) + self.__interruptibleSleep(0.10) + else: + refresh = True if rendering_method == "refresh" else False + if not refresh: + message = f"\n{message}\n" + self.__sendMessageCore(key, message, refresh) + + def __showMessage(self, text, message, refresh): + text.configure(state="normal") + try: + pos = text.index('end') + except: + return + + if refresh: + text.delete('1.0', 'end') + pos = text.index('end') + + text.insert(pos, message) + text.see("end") + text.configure(state="disabled") + + def __receiveMessage(self): + for key in self.__sub_windows.keys(): + message_queue = self.__sub_windows[key]["message_queue"] + text = self.__sub_windows[key]["text"] + while not message_queue.empty(): + slot = message_queue.get() + self.__showMessage(text, slot.message, slot.refresh) + + self.__root.after(ms=33, func=self.__receiveMessage) + + def __init__(self, workspace="./"): + self.__running = False + self.__variable_cache_path = os.path.join(workspace, "variable_cache.yaml") + self.__setting_path = os.path.join(workspace, "setting.yaml") + + variable_cache = {} + try: + with open(self.__variable_cache_path, 'r') as file: + variable_cache = yaml.safe_load(file) + except: + variable_cache["video_id"] = "" + variable_cache["api_key"] = "" + + with open(self.__setting_path, 'r', encoding='shift_jis') as file: + settings = yaml.safe_load(file) + + self.__voicevox_path = settings['voicevox_path'] + + self.__sub_window_settings = {} + self.__sub_window_settings["chat"] = {} + self.__sub_window_settings["chat"]["display_user_name"] = settings["display_user_name_on_chat_window"] + self.__sub_window_settings["chat"]["title"] = settings["chat_window_title"] + self.__sub_window_settings["chat"]["window_size"] = settings["chat_window_size"] + self.__sub_window_settings["chat"]["window_padx"] = settings["chat_window_padx"] + self.__sub_window_settings["chat"]["window_pady"] = settings["chat_window_pady"] + self.__sub_window_settings["chat"]["window_color"] = settings["chat_window_color"] + self.__sub_window_settings["chat"]["font_color"] = settings["chat_font_color"] + self.__sub_window_settings["chat"]["font_size"] = settings["chat_font_size"] + self.__sub_window_settings["chat"]["font_type"] = settings["chat_font_type"] + self.__sub_window_settings["chat"]["rendering_method"] = settings["chat_rendering_method"] + self.__sub_window_settings["chat"]["display_name"] = settings["display_user_name_on_chat_window"] + + self.__sub_window_settings["ask"] = {} + self.__sub_window_settings["ask"]["display_user_name"] = settings["display_user_name_on_ask_window"] + self.__sub_window_settings["ask"]["title"] = settings["ask_window_title"] + self.__sub_window_settings["ask"]["window_size"] = settings["ask_window_size"] + self.__sub_window_settings["ask"]["window_padx"] = settings["ask_window_padx"] + self.__sub_window_settings["ask"]["window_pady"] = settings["ask_window_pady"] + self.__sub_window_settings["ask"]["window_color"] = settings["ask_window_color"] + self.__sub_window_settings["ask"]["font_color"] = settings["ask_font_color"] + self.__sub_window_settings["ask"]["font_size"] = settings["ask_font_size"] + self.__sub_window_settings["ask"]["font_type"] = settings["ask_font_type"] + self.__sub_window_settings["ask"]["rendering_method"] = settings["ask_rendering_method"] + self.__sub_window_settings["ask"]["display_name"] = settings["display_user_name_on_ask_window"] + + self.__sub_window_settings["answer"] = {} + self.__sub_window_settings["answer"]["title"] = settings["answer_window_title"] + self.__sub_window_settings["answer"]["window_size"] = settings["answer_window_size"] + self.__sub_window_settings["answer"]["window_padx"] = settings["answer_window_padx"] + self.__sub_window_settings["answer"]["window_pady"] = settings["answer_window_pady"] + self.__sub_window_settings["answer"]["window_color"] = settings["answer_window_color"] + self.__sub_window_settings["answer"]["font_color"] = settings["answer_font_color"] + self.__sub_window_settings["answer"]["font_size"] = settings["answer_font_size"] + self.__sub_window_settings["answer"]["font_type"] = settings["answer_font_type"] + self.__sub_window_settings["answer"]["rendering_method"] = settings["answer_rendering_method"] + self.__sub_window_settings["answer"]["display_name"] = False + + stream_params = zm.streamParams( + video_id = variable_cache["video_id"], + ) + + ai_params = zm.aiParams( + api_key = variable_cache["api_key"], + model = settings["model"], + system_role = settings["system_role"], + max_tokens_per_request = settings["max_tokens_per_request"], + interval_sec = settings["ask_interval_sec"] + ) + + volume = (lambda v: 0 if v < 0 else 500 if v > 500 else v)(settings['volume']) + + self.__zm_streamer_params = zm.params( stream_params=stream_params, + ai_params=ai_params, + speaker_type = settings["speaker_type"], + volume=volume, + send_message_cb=self.__sendMessage ) + + self.__manager = None + self.__root = tk.Tk() + self.__root.resizable(False, False) + self.__root.protocol("WM_DELETE_WINDOW", self.__close) + self.__initial_volume = volume + self.__icon = os.path.join(workspace, "zundamon_icon1.ico") + self.__widgits_start = {} + self.__widgits_main = {} + self.__sub_windows = {} + self.__image_command_path = os.path.join(workspace, "TransparentViewer.exe") + self.__image_command_args = "-c" + self.__portrait_window_process = None + self.__portrait_image_file = settings["image_file"] + self.__portrait_visible_file_path = os.path.join(workspace, "viewer_visible.txt") + try: + os.remove(self.__portrait_visible_file_path) + except: + pass + self.__createStartWindow() + + def __start(self): + self.__running = True + + variables = {} + if self.__widgits_start["video_id_entry"].get() == "": + variables["video_id"] = self.__zm_streamer_params.stream_params.video_id + else: + variables["video_id"] = self.__widgits_start["video_id_entry"].get() + self.__zm_streamer_params.stream_params.video_id = variables["video_id"] + if self.__widgits_start["api_key_entry"].get() == "": + variables["api_key"] = self.__zm_streamer_params.ai_params.api_key + else: + variables["api_key"] = self.__widgits_start["api_key_entry"].get() + self.__zm_streamer_params.ai_params.api_key = variables["api_key"] + + file = open(self.__variable_cache_path, 'w', encoding='UTF-8') + yaml.safe_dump(variables, file) + file.close() + + self.__root.title("めいんういんどう") + self.__clearStartWindow() + self.__createMainWindow() + self.__createMessageWindow(key = "chat") + self.__createMessageWindow(key = "ask") + self.__createMessageWindow(key = "answer") + + visible_file_generated = False + + while (not visible_file_generated): + try: + file = open(self.__portrait_visible_file_path, mode='w') + except: + continue + file.write("false") + file.close() + visible_file_generated = True + + self.__portrait_window_process = subprocess.Popen(f"{self.__image_command_path} {self.__image_command_args} {self.__portrait_image_file}") + + self.__manager = zm.ZundamonAIStreamerManager(self.__zm_streamer_params) + self.__manager.start() + + def __changeVolume(self, event=None): + volume = int(self.__widgits_main["scaleVolume"].get()) + self.__widgits_main["volumeEntry"].delete(0, tk.END) + self.__widgits_main["volumeEntry"].insert(0, str(volume)) + if self.__manager: + self.__manager.volume = volume + + def __scaleVolume(self, event=None): + volume_str = self.__widgits_main["volumeEntry"].get() + try: + volume = int(volume_str) + except: + return + + volume = (lambda v: 0 if v < 0 else 500 if v > 500 else v)(volume) + + self.__widgits_main["volumeEntry"].delete(0, tk.END) + self.__widgits_main["volumeEntry"].insert(0, str(volume)) + + self.__widgits_main["scaleVolume"].set(volume) + + def __changeVisible(self, key): + if self.__sub_windows[key]["visible"]: + self.__sub_windows[key]["visible"] = False + self.__sub_windows[key]["body"].withdraw() + else: + self.__sub_windows[key]["visible"] = True + self.__sub_windows[key]["body"].deiconify() + + def __changeVisiblePortrait(self): + try: + file = open(self.__portrait_visible_file_path, mode='r') + except: + self.__root.after(ms=33, func=self.__changeVisiblePortrait) + return + + visible_str = file.readline().splitlines()[0] + visible = True if visible_str == "true" else False + + try: + file = open(self.__portrait_visible_file_path, mode='w') + except: + self.__root.after(ms=33, func=self.__changeVisiblePortrait) + return + + visible = not visible + file.write("true" if visible else "false") + file.close() + + def __clickWindow(self, key, event): + self.__sub_windows[key]["mouse_position"][0] = event.x_root + self.__sub_windows[key]["mouse_position"][1] = event.y_root + + def __moveWindow(self, key, event): + moved_x = event.x_root - self.__sub_windows[key]["mouse_position"][0] + moved_y = event.y_root - self.__sub_windows[key]["mouse_position"][1] + self.__sub_windows[key]["mouse_position"][0] = event.x_root + self.__sub_windows[key]["mouse_position"][1] = event.y_root + cur_position_x = self.__sub_windows[key]["body"].winfo_x() + moved_x + cur_position_y = self.__sub_windows[key]["body"].winfo_y() + moved_y + self.__sub_windows[key]["body"].geometry(f"+{cur_position_x}+{cur_position_y}") + pass + + def __doubleclickWindow(self, key, event=None): + self.__sub_windows[key]["body"].wm_overrideredirect(not self.__sub_windows[key]["body"].wm_overrideredirect()) + + def __close(self): + self.__running = False + + if self.__portrait_window_process: + self.__portrait_window_process.kill() + if self.__manager: + self.__manager.disconnect() + self.__manager.join() + self.__root.destroy() + try: + os.remove(self.__portrait_visible_file_path) + except: + pass + + def mainloop(self): + self.__root.mainloop() + +if __name__ == "__main__": + ui = ZundamonAIStreamerUI(workspace=os.path.abspath(os.path.dirname(sys.argv[0]))) + ui.mainloop() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..6e79ed6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,5 @@ +from .ZundamonAIStreamer import * +from .ZundamonAIStreamerManager import * +from .ZundamonAIStreamerUI import * + +__version__ = '2.0.0' diff --git a/zundamon.gif b/zundamon.gif new file mode 100644 index 0000000..8e52425 --- /dev/null +++ b/zundamon.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:beb649c7ac7cd11d78406e3b9eca07e85b1a671fa3b0a6f5fdd6802eeb8b7fbd +size 2754369 diff --git a/zundamon.png b/zundamon.png new file mode 100644 index 0000000..c5d7515 Binary files /dev/null and b/zundamon.png differ diff --git a/zundamon_icon1.ico b/zundamon_icon1.ico new file mode 100644 index 0000000..8f5b8ec Binary files /dev/null and b/zundamon_icon1.ico differ