diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU 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. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..41d3ba9 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +
+
+ Fay +

FAY

+

数 字 人 控 制 器

+
+ + +​ 本开源项目名为“数字人控制器”。意为,本项目可以充当时下流行的虚拟人、虚拟主播、数字人,等仿人形数字形象的内核部分。 + +​ 使用UE、C4D、DAZ、LIVE2D等三维引擎软件开发的数字形象可以与本“数字人控制器”对接,从而实现虚拟主播、数字导游、数字助手等。我们提供UE4对接的demo,但我们更鼓励用户自行实现喜欢的数字形象。 + +​ 当然,若不考虑外观形象的话,本“数字人控制器”其实也可以独立使用的,可以充当一个语音助理。 + + + +## 环境 + +- Python 3.8.0 + + +- Chrome 浏览器 (若不开启直播功能,可跳过) + + + + + + +## 安装 + +### 安装依赖 + +```shell +pip install -r requirements.txt +``` + + + + + +### 配置 ChromeDriver (若不开启直播功能,可跳过) + +1. Chrome 浏览器进入 [`chrome://settings/help`](chrome://settings/help) 查看当前版本 +2. 下载对应版本 [ChromeDriver](https://chromedriver.chromium.org/downloads) +3. 解压zip并拷贝至 ./bin 目录 +4. 编辑 system.conf 配置 ChromeDriver 路径 + + + + + +### 配置应用密钥 + +1. 查看 [AI 模块](#ai-模块) + +2. 浏览链接,注册并创建应用,将应用密钥填入 `./system.conf` 中 + + + +## 启动 + +启动数字人图像控制器 + +```shell +python main.py +``` + + + + + +## 图形界面 + +![](images/controller.png) + +### 人设 + +数字人属性,与用户交互中能做出相应的响应。 + +##### 交互灵敏度 + +在交互中,数字人能感受用户的情感,并作出反应。最直的体现,就是语气的变化,如 开心/伤心/生气 等。 + +设置灵敏度,可改变用户情感对于数字人的影响程度。 + + + + + +### 接收来源 + +#### 抖音 + +填入直播间地址,实现与直播间粉丝交互 + + + + + +#### 麦克风 + +选择麦克风设备,实现面对面交互,成为你的伙伴 + + + + + +#### 商品栏 + +填入商品介绍,数字人将自动讲解商品。 + +当用户对商品有疑问时,数字人可自动跳转至对应商品并解答问题。 + +配合抖音接收来源,实现直播间自动带货。 + + + +## AI 模块 + + + +启动前需填入应用密钥 + +| 模块 | 描述 | 链接 | +| ------------------------- | -------------------------- | ------------------------------------------------------------ | +| ./ai_module/ali_nls.py | 阿里云 实时语音识别 | https://ai.aliyun.com/nls/trans | +| ./ai_module/ms_tts_sdk.py | 微软 文本转语音 基于SDK | https://azure.microsoft.com/zh-cn/services/cognitive-services/text-to-speech/ | +| ./ai_module/xf_aiui.py | 讯飞 人机交互-自然语言处理 | https://aiui.xfyun.cn/solution/webapi | +| ./ai_module/xf_ltp.py | 讯飞 情感分析 | https://www.xfyun.cn/service/emotion-analysis | + + + + + +## 与数字形象通讯(非必须) + +控制器与采用 WebSocket 方式与 UE 通讯 + +通讯地址: [`ws://127.0.0.1:10002`](ws://127.0.0.1:10002) + +消息格式: 查看 [WebSocket.md](https://github.com/TheRamU/Fay/blob/main/WebSocket.md) + +![](images/UE.png) + + + +## 目录结构 + +``` +. +├── main.py # 程序主入口 +├── fay_booter.py # 核心启动模块 +├── config.json # 控制器配置文件 +├── system.conf # 系统配置文件 +├── ai_module +│   ├── ali_nls.py # 阿里云 实时语音 +│   ├── ms_tts_sdk.py # 微软 文本转语音 +│   ├── xf_aiui.py # 讯飞 人机交互-自然语言处理 +│   └── xf_ltp.py # 讯飞 性感分析 +├── bin # 可执行文件目录 +├── core # 数字人核心 +│   ├── fay_core.py # 数字人核心模块 +│   ├── recorder.py # 录音器 +│   ├── tts_voice.py # 语音生源枚举 +│   ├── viewer.py # 抖音直播间接入模块 +│   └── wsa_server.py # WebSocket 服务端 +├── gui # 图形界面 +│   ├── flask_server.py # Flask 服务端 +│   ├── static +│   ├── templates +│   └── window.py # 窗口模块 +├── scheduler +│   └── thread_manager.py # 调度管理器 +└── utils # 工具模块 + ├── config_util.py + ├── storer.py + └── util.py +``` + diff --git a/WebSocket.md b/WebSocket.md new file mode 100644 index 0000000..91b7d0c --- /dev/null +++ b/WebSocket.md @@ -0,0 +1,50 @@ +## 消息格式 + +通讯地址: [`ws://127.0.0.1:10002`](ws://127.0.0.1:10002) + + + +### 发送情绪值 + +` +{ + "Topic": "Unreal", + "Data": { + "Key": "mood", + "Value": 1.0 + } +} +` + + + +| 参数 | 描述 | 类型 | 范围 | +| ---------- | ------ | ----- | ------- | +| Data.Value | 情绪值 | float | [-1, 1] | + + + + + +### 发送音频 + +` +{ + "Topic": "Unreal", + "Data": { + "Key": "audio", + "Value": "C:\samples\sample-1.mp3", + "Time": 10, + "Type": "interact" + } +} +` + + + +| 参数 | 描述 | 类型 | 范围 | +| ---------- | ---------------- | ----- | --------------- | +| Data.Value | 音频文件绝对路径 | str | | +| Data.Time | 音频时长 (秒) | float | | +| Data.Type | 发言类型 | str | interact/script | + diff --git a/ai_module/ali_nls.py b/ai_module/ali_nls.py new file mode 100644 index 0000000..70ce2b8 --- /dev/null +++ b/ai_module/ali_nls.py @@ -0,0 +1,173 @@ +from threading import Thread + +import websocket +import json +import time +import ssl +import _thread as thread +from aliyunsdkcore.client import AcsClient +from aliyunsdkcore.request import CommonRequest + +from core import wsa_server +from scheduler.thread_manager import MyThread +from utils import util +from utils import config_util as cfg + +__running = True +__my_thread = None +_token = '' + + +def __post_token(): + global _token + __client = AcsClient( + cfg.key_ali_nls_key_id, + cfg.key_ali_nls_key_secret, + "cn-shanghai" + ) + + __request = CommonRequest() + __request.set_method('POST') + __request.set_domain('nls-meta.cn-shanghai.aliyuncs.com') + __request.set_version('2019-02-28') + __request.set_action_name('CreateToken') + _token = json.loads(__client.do_action_with_exception(__request))['Token']['Id'] + + +def __runnable(): + while __running: + __post_token() + time.sleep(60 * 60 * 12) + + +def start(): + MyThread(target=__runnable).start() + + +class ALiNls: + # 初始化 + def __init__(self): + self.__URL = 'wss://nls-gateway-cn-shenzhen.aliyuncs.com/ws/v1' + self.__ws = None + self.__connected = False + self.__frames = [] + self.__state = 0 + self.__closing = False + self.__task_id = '' + self.done = False + self.finalResults = "" + + def __create_header(self, name): + if name == 'StartTranscription': + self.__task_id = util.random_hex(32) + header = { + "appkey": cfg.key_ali_nls_app_key, + "message_id": util.random_hex(32), + "task_id": self.__task_id, + "namespace": "SpeechTranscriber", + "name": name + } + return header + + # 收到websocket消息的处理 + def on_message(self, ws, message): + try: + data = json.loads(message) + header = data['header'] + name = header['name'] + if name == 'SentenceEnd': + self.done = True + self.finalResults = data['payload']['result'] + wsa_server.get_web_instance().add_cmd({"panelMsg": self.finalResults}) + elif name == 'TranscriptionResultChanged': + self.finalResults = data['payload']['result'] + wsa_server.get_web_instance().add_cmd({"panelMsg": self.finalResults}) + + except Exception as e: + print(e) + # print("### message:", message) + if self.__closing: + try: + self.__ws.close() + except Exception as e: + print(e) + + # 收到websocket错误的处理 + def on_close(self, ws, code, msg): + self.__connected = False + print("### CLOSE:", msg) + + # 收到websocket错误的处理 + def on_error(self, ws, error): + print("### error:", error) + + # 收到websocket连接建立的处理 + def on_open(self, ws): + self.__connected = True + + # print("连接上了!!!") + + def run(*args): + while self.__connected: + try: + if len(self.__frames) > 0: + frame = self.__frames[0] + self.__frames.pop(0) + if type(frame) == dict: + ws.send(json.dumps(frame)) + elif type(frame) == bytes: + ws.send(frame, websocket.ABNF.OPCODE_BINARY) + # print('发送 ------> ' + str(type(frame))) + except Exception as e: + print(e) + time.sleep(0.04) + + thread.start_new_thread(run, ()) + + def __connect(self): + self.finalResults = "" + self.done = False + self.__frames.clear() + websocket.enableTrace(False) + self.__ws = websocket.WebSocketApp(self.__URL + '?token=' + _token, on_message=self.on_message) + self.__ws.on_open = self.on_open + self.__ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) + + def add_frame(self, frame): + self.__frames.append(frame) + + def send(self, buf): + self.__frames.append(buf) + + def start(self): + Thread(target=self.__connect, args=[]).start() + data = { + 'header': self.__create_header('StartTranscription'), + "payload": { + "format": "pcm", + "sample_rate": 16000, + "enable_intermediate_result": True, + "enable_punctuation_prediction": False, + "enable_inverse_text_normalization": True, + "speech_noise_threshold": -1 + } + } + self.add_frame(data) + + def end(self): + if self.__connected: + try: + for frame in self.__frames: + self.__frames.pop(0) + if type(frame) == dict: + self.__ws.send(json.dumps(frame)) + elif type(frame) == bytes: + self.__ws.send(frame, websocket.ABNF.OPCODE_BINARY) + time.sleep(0.4) + self.__frames.clear() + frame = {"header": self.__create_header('StopTranscription')} + self.__ws.send(json.dumps(frame)) + except Exception as e: + print(e) + self.__closing = True + self.__connected = False diff --git a/ai_module/ms_tts_sdk.py b/ai_module/ms_tts_sdk.py new file mode 100644 index 0000000..fade465 --- /dev/null +++ b/ai_module/ms_tts_sdk.py @@ -0,0 +1,68 @@ +import time + +import azure.cognitiveservices.speech as speechsdk + +from core import tts_voice +from core.tts_voice import EnumVoice +from utils import util, config_util +from utils import config_util as cfg + + +class Speech: + def __init__(self): + self.__speech_config = speechsdk.SpeechConfig(subscription=cfg.key_ms_tts_key, region="eastasia") + self.__speech_config.speech_recognition_language = "zh-CN" + self.__speech_config.speech_synthesis_voice_name = "zh-CN-XiaoxiaoNeural" + self.__speech_config.set_speech_synthesis_output_format(speechsdk.SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3) + self.__synthesizer = speechsdk.SpeechSynthesizer(speech_config=self.__speech_config, audio_config=None) + self.__connection = None + self.__history_data = [] + + def __get_history(self, voice_name, style, text): + for data in self.__history_data: + if data[0] == voice_name and data[1] == style and data[2] == text: + return data[3] + return None + + def connect(self): + self.__connection = speechsdk.Connection.from_speech_synthesizer(self.__synthesizer) + self.__connection.open(True) + util.log(1, "TTS 服务已经连接!") + + def close(self): + if self.__connection is not None: + self.__connection.close() + + """ + 文字转语音 + :param text: 文本信息 + :param style: 说话风格、语气 + :returns: 音频文件路径 + """ + + def to_sample(self, text, style): + voice_type = tts_voice.get_voice_of(config_util.config["attribute"]["voice"]) + voice_name = EnumVoice.XIAO_XIAO.value["voiceName"] + if voice_type is not None: + voice_name = voice_type.value["voiceName"] + history = self.__get_history(voice_name, style, text) + if history is not None: + return history + ssml = '' \ + '' \ + '' \ + '{}' \ + '' \ + '' \ + ''.format(voice_name, style, 1.8, text) + result = self.__synthesizer.speak_ssml(ssml) + audio_data_stream = speechsdk.AudioDataStream(result) + file_url = './samples/sample-' + str(int(time.time() * 1000)) + '.mp3' + audio_data_stream.save_to_wav_file(file_url) + if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted: + self.__history_data.append((voice_name, style, text, file_url)) + return file_url + else: + util.log(1, "[x] 语音转换失败!") + util.log(1, "[x] 原因: " + str(result.reason)) + return None diff --git a/ai_module/xf_aiui.py b/ai_module/xf_aiui.py new file mode 100644 index 0000000..9df655e --- /dev/null +++ b/ai_module/xf_aiui.py @@ -0,0 +1,107 @@ +import json +import time + +from ws4py.client.threadedclient import WebSocketClient +import base64 +import hashlib +import uuid +from utils import config_util as cfg + +base_url = "ws://wsapi.xfyun.cn/v1/aiui" + +end_tag = "--end--" + + +# qa 通讯类 +class __WSClient(WebSocketClient): + q_msg = '' + a_msg = '' + + def opened(self): + pass + + def closed(self, code, reason=None): + # if code == 1000: + # print("qa close") + # else: + # print("连接异常关闭,code:" + str(code) + " ,reason:" + str(reason)) + return + + def received_message(self, m): + + s = json.loads(str(m)) + + if s['action'] == "started": + + # 输入内容并发送 + str_content = self.q_msg + self.send(bytes(str_content.encode('utf-8'))) + time.sleep(0.04) + + # 数据发送结束之后发送结束标识 + self.send(bytes(end_tag.encode("utf-8"))) + + elif s['action'] == "result": + data = s['data'] + # with open('qa/out.txt', 'w') as file: + # file.write(str(data)) + if data['sub'] == "iat": + print("user: ", data["text"]) + elif data['sub'] == "nlp": + intent = data['intent'] + if intent['rc'] == 0: + self.a_msg = intent['answer']['text'] + else: + self.a_msg = "我没有理解你说的话啊" + elif data['sub'] == "tts": + # TODO 播报pcm音频 + print('tts') + pass + elif s['action'] == "error": + print('[NLP错误] ' + s['desc']) + else: + print(s) + + +def __get_auth_id(): + mac = uuid.UUID(int=uuid.getnode()).hex[-12:] + return hashlib.md5(":".join([mac[e:e + 2] for e in range(0, 11, 2)]).encode("utf-8")).hexdigest() + + +def question(text): + ws = None + try: + # 构造握手参数 + curTime = int(time.time()) + + auth_id = __get_auth_id() + + param = """{{ + "auth_id": "{0}", + "data_type": "text", + "scene": "main_box", + "ver_type": "monitor", + "close_delay": "200", + "ent":"xtts", + "vcn":"x_xiaoyan", + "speed":"50", + "interact_mode":"continuous", + "context": "{{\\\"sdk_support\\\":[\\\"iat\\\",\\\"nlp\\\",\\\"tts\\\"]}}" + }}""" + + param = param.format(auth_id).encode(encoding="utf-8") + paramBase64 = base64.b64encode(param).decode() + checkSumPre = cfg.key_xf_aiui_api_key + str(curTime) + paramBase64 + checksum = hashlib.md5(checkSumPre.encode("utf-8")).hexdigest() + connParam = "?appid=" + cfg.key_xf_aiui_app_id + "&checksum=" + checksum + "¶m=" + paramBase64 + "&curtime=" + str(curTime) + "&signtype=md5" + + ws = __WSClient(base_url + connParam, protocols=['chat'], headers=[("Origin", "https://wsapi.xfyun.cn")]) + ws.q_msg = text + ws.connect() + ws.run_forever() + + except KeyboardInterrupt: + if ws is not None: + ws.close() + + return ws.a_msg diff --git a/ai_module/xf_ltp.py b/ai_module/xf_ltp.py new file mode 100644 index 0000000..51771f6 --- /dev/null +++ b/ai_module/xf_ltp.py @@ -0,0 +1,59 @@ +import time +import urllib.request +import urllib.parse +import json +import hashlib +import base64 +from utils import config_util as cfg + +__URL = "https://ltpapi.xfyun.cn/v2/sa" + + +def __quest(text): + body = urllib.parse.urlencode({'text': text}).encode('utf-8') + param = {"type": "dependent"} + x_param = base64.b64encode(json.dumps(param).replace(' ', '').encode('utf-8')) + x_time = str(int(time.time())) + x_checksum = hashlib.md5(cfg.key_xf_ltp_api_key.encode('utf-8') + str(x_time).encode('utf-8') + x_param).hexdigest() + x_header = { + 'X-Appid': cfg.key_xf_ltp_app_id, + 'X-CurTime': x_time, + 'X-Param': x_param, + 'X-CheckSum': x_checksum + } + req = urllib.request.Request(__URL, body, x_header) + result = urllib.request.urlopen(req) + result = result.read() + return json.loads(result.decode('utf-8')) + + +""" +情感分析 + +:param text: 文本 + +:returns: 情感分数 (0.7以上为褒义, 0.3-0.7为中性 0.3以下为贬义,, -1为分析失败) +""" + + +def get_score(text): + result = __quest(text) + if result['desc'] == 'success': + return float(result['data']['score']) + return -1 + + +""" +情感分析 + +:param text: 文本 + +:returns: 情感极性分类 (2为褒义, 1为中性 0为贬义,, -1为分析失败) +""" + + +def get_sentiment(text): + result = __quest(text) + if result['desc'] == 'success': + return int(result['data']['sentiment']) + 1 + return -1 diff --git a/config.json b/config.json new file mode 100644 index 0000000..39f3c37 --- /dev/null +++ b/config.json @@ -0,0 +1,52 @@ +{ + "attribute": { + "age": "成年", + "birth": "中国", + "constellation": "水瓶座", + "contact": "微信123456789", + "gender": "男", + "hobby": "发呆", + "job": "产品布道者", + "name": "陈升", + "voice": "YUN_XI", + "zodiac": "蛇" + }, + "interact": { + "QnA": "E:/QnA/全局QnA.xlsx", + "maxInteractTime": 15, + "perception": { + "chat": 7, + "follow": 10, + "gift": 50, + "indifferent": 10, + "join": 10 + }, + "playSound": false + }, + "items": [ + { + "QnA": "E:/QnA/商品QnA.xlsx", + "demoVideo": "C:/Demo.mp4", + "enabled": false, + "explain": { + "character": "", + "discount": "", + "intro": "", + "price": "", + "promise": "", + "usage": "" + }, + "name": "" + } + ], + "source": { + "liveRoom": { + "enabled": false, + "url": "https://live.douyin.com/" + }, + "record": { + "device": "", + "enabled": false + } + } +} \ No newline at end of file diff --git a/core/fay_core.py b/core/fay_core.py new file mode 100644 index 0000000..5a404fb --- /dev/null +++ b/core/fay_core.py @@ -0,0 +1,504 @@ +import difflib +import math +import os +import random +import time +import wave + +import eyed3 +from openpyxl import load_workbook + +# 适应模型使用 +import numpy as np +# import tensorflow as tf + +from ai_module import xf_aiui +from ai_module import xf_ltp +from ai_module.ms_tts_sdk import Speech +from core import wsa_server, tts_voice +from core.tts_voice import EnumVoice +from scheduler.thread_manager import MyThread +from utils import util, storer, config_util + +import pygame + + +class FeiFei: + def __init__(self): + pygame.init() + self.q_msg = '你叫什么名字?' + self.a_msg = 'hi,我叫菲菲,英文名是fay' + self.mood = 0.0 # 情绪值 + self.item_index = 0 + + self.X = np.array([1, 0, 0, 0, 0, 0, 0, 0]).reshape(1, -1) # 适应模型变量矩阵 + # self.W = np.array([0.01577594,1.16119452,0.75828,0.207746,1.25017864,0.1044121,0.4294899,0.2770932]).reshape(-1,1) #适应模型变量矩阵 + self.W = np.array([0.0, 0.6, 0.1, 0.7, 0.3, 0.0, 0.0, 0.0]).reshape(-1, 1) # 适应模型变量矩阵 + + # 人设提问关键字 + self.attribute_keyword = [ + [['你叫什么名字', '你的名字是什么'], 'name'], + [['你是男的还是女的', '你是男生还是女生', '你的性别是什么', '你是男生吗', '你是女生吗', '你是男的吗', '你是女的吗', '你是男孩子吗', '你是女孩子吗', ], 'gender', ], + [['你今年多大了', '你多大了', '你今年多少岁', '你几岁了', '你今年几岁了', '你今年几岁了', '你什么时候出生', '你的生日是什么', '你的年龄'], 'age', ], + [['你的家乡在哪', '你的家乡是什么', '你家在哪', '你住在哪', '你出生在哪', '你的出生地在哪', '你的出生地是什么', ], 'birth', ], + [['你的生肖是什么', '你属什么', ], 'zodiac', ], + [['你是什么座', '你是什么星座', '你的星座是什么', ], 'constellation', ], + [['你是做什么的', '你的职业是什么', '你是干什么的', '你的职位是什么', '你的工作是什么', '你是做什么工作的'], 'job', ], + [['你的爱好是什么', '你有爱好吗', '你喜欢什么', '你喜欢做什么'], 'hobby'], + [['联系方式', '联系你们', '怎么联系客服', '有没有客服'], 'contact'] + ] + + # 商品提问关键字 + self.explain_keyword = [ + [['是什么'], 'intro'], + [['怎么用', '使用场景', '有什么作用'], 'usage'], + [['怎么卖', '多少钱', '售价'], 'price'], + [['便宜点', '优惠', '折扣', '促销'], 'discount'], + [['质量', '保证', '担保'], 'promise'], + [['特点', '优点'], 'character'], + ] + + self.wsParam = None + self.wss = None + self.sp = Speech() + self.speaking = False + self.last_interact_time = time.time() + self.last_speak_data = '' + self.interactive = [] + self.sleep = False + self.__running = True + self.sp.connect() # 预连接 + self.last_quest_time = time.time() + + def __string_similar(self, s1, s2): + return difflib.SequenceMatcher(None, s1, s2).quick_ratio() + + def __read_qna(self, filename) -> list: + qna = [] + try: + wb = load_workbook(filename) + sheets = wb.worksheets # 获取当前所有的sheet + sheet = sheets[0] + for row in sheet.rows: + if len(row) >= 2: + qna.append([row[0].value.split(";"), row[1].value]) + except BaseException as e: + print("无法读取Q&A文件 {} -> ".format(filename) + str(e)) + return qna + + def __get_keyword(self, keyword_dict, text): + last_similar = 0 + last_answer = '' + for qa in keyword_dict: + for quest in qa[0]: + similar = self.__string_similar(text, quest) + if quest in text: + similar += 0.3 + if similar > last_similar: + last_similar = similar + last_answer = qa[1] + if last_similar >= 0.6: + return last_answer + return None + + def __get_answer(self, text): + + # 人设问答 + keyword = self.__get_keyword(self.attribute_keyword, text) + if keyword is not None: + return config_util.config["attribute"][keyword] + + # 全局问答 + answer = self.__get_keyword(self.__read_qna(config_util.config['interact']['QnA']), text) + if answer is not None: + return answer + + items = self.__get_item_list() + + if len(items) > 0: + item = items[self.item_index] + + # 跨商品物品问答匹配 + for ite in items: + name = ite["name"] + if name != item["name"]: + if name in text or self.__string_similar(text, name) > 0.6: + item = ite + break + + # 商品介绍问答 + keyword = self.__get_keyword(self.explain_keyword, text) + if keyword is not None: + try: + return item["explain"][keyword] + except BaseException as e: + print(e) + + # 商品问答 + answer = self.__get_keyword(self.__read_qna(item["QnA"]), text) + if answer is not None: + return answer + + return None + + def __get_list_answer(self, answers, text): + last_similar = 0 + last_answer = '' + for mlist in answers: + for quest in mlist[0]: + similar = self.__string_similar(text, quest) + if quest in text: + similar += 0.3 + if similar > last_similar: + last_similar = similar + answer_list = mlist[1] + last_answer = answer_list[random.randint(0, len(answer_list) - 1)] + # print("相似度: {}, 回答: {}".format(last_similar, last_answer)) + if last_similar >= 0.6: + return last_answer + return None + + def __auto_speak(self): + i = 0 + script_index = 0 + while self.__running: + time.sleep(0.8) + if self.speaking or self.sleep: + continue + + try: + # 简化逻辑:默认执行带货脚本,带货脚本执行其间有人互动,则执行完当前脚本就回应最后三条互动,回应完继续执行带货脚本 + if i <= 3 and len(self.interactive) > i: + i += 1 + interact = self.interactive[0 - i] + if interact[0] == 1: + self.q_msg = interact[2] + index = interact[0] + # print("index:{0}".format(index)) + user_name = interact[1] + # self.__isExecute = True #!!!! + + if index == 1: + answer = self.__get_answer(self.q_msg) + text = '' + if answer is None: + try: + wsa_server.get_web_instance().add_cmd({"panelMsg": "思考中..."}) + util.log(1, '自然语言处理...') + tm = time.time() + text = xf_aiui.question(self.q_msg) + util.log(1, '自然语言处理完成. 耗时: {} ms'.format(math.floor((time.time() - tm) * 1000))) + if text == '哎呀,你这么说我也不懂,详细点呗' or text == '': + util.log(1, '[!] 自然语言无语了!') + wsa_server.get_web_instance().add_cmd({"panelMsg": ""}) + continue + except BaseException as e: + print(e) + util.log(1, '自然语言处理错误!') + wsa_server.get_web_instance().add_cmd({"panelMsg": ""}) + continue + else: + text = answer + if len(user_name) == 0: + self.a_msg = text + else: + self.a_msg = user_name + ',' + text + + elif index == 2: + self.a_msg = ['我们的直播间越来越多人咯', '感谢{}的到来'.format(user_name), '欢印{}来到我们的直播间'.format(user_name)][ + random.randint(0, 2)] + + elif index == 3: + msg = "" + for index in range(1, len(interact), 4): + try: + gift = interact[index + 2] + gift_name = '礼物' + if gift[0] != -1: + gift_name = gift[1] + msg = msg + "{}送给我的{}个{},".format(interact[index], interact[index + 3], gift_name) + except BaseException as e: + print("[System] 礼物处理错误!") + print(e) + self.a_msg = '感谢感谢,感谢' + msg + + elif index == 4: + self.a_msg = '感谢关注' + + self.last_speak_data = self.a_msg + self.speaking = True + MyThread(target=self.__say, args=['interact']).start() + else: + i = 0 + self.interactive.clear() + config_items = config_util.config["items"] + items = [] + for item in config_items: + if item["enabled"]: + items.append(item) + if len(items) > 0: + if self.item_index >= len(items): + self.item_index = 0 + script_index = 0 + item = items[self.item_index] + script_index = script_index + 1 + explain_key = self.__get_explain_from_index(script_index) + if explain_key is None: + self.item_index = self.item_index + 1 + script_index = 0 + if self.item_index >= len(items): + self.item_index = 0 + explain_key = self.__get_explain_from_index(script_index) + explain = item["explain"][explain_key] + if len(explain) > 0: + self.a_msg = explain + self.last_speak_data = self.a_msg + self.speaking = True + MyThread(target=self.__say, args=['script']).start() + except BaseException as e: + print(e) + + def __get_item_list(self) -> list: + items = [] + for item in config_util.config["items"]: + if item["enabled"]: + items.append(item) + return items + + def __get_explain_from_index(self, index: int): + if index == 0: + return "character" + if index == 1: + return "discount" + if index == 2: + return "intro" + if index == 3: + return "price" + if index == 4: + return "promise" + if index == 5: + return "usage" + return None + + def on_interact(self, interact): + + # 合并同类交互 + # 进入 + if interact[0] == 2: + itr = self.__get_interactive(2) + if itr is None: + self.interactive.append(interact) + else: + newItr = (2, itr[1] + ', ' + interact[1], itr[2]) + self.interactive.remove(itr) + self.interactive.append(newItr) + + # 送礼 + elif interact[0] == 3: + itr = self.__get_interactive(3) + if itr is None: + self.interactive.append(interact) + else: + newItrList = [] + newItrList.extend(itr) + newItrList.append(itr[2]) + newItrList.append(itr[3]) + newItrList.append(itr[4]) + self.interactive.remove(itr) + self.interactive.append(tuple(newItrList)) + + # 关注 + elif interact[0] == 4: + if self.__get_interactive(2) is None: + self.interactive.append(interact) + + else: + self.interactive.append(interact) + MyThread(target=self.__update_mood, args=[interact[0]]).start() + MyThread(target=storer.storage_live_interact, args=[interact]).start() + + def __get_interactive(self, interactType): + for interact in self.interactive: + if interact[0] == interactType: + return interact + return None + + # 适应模型计算 + def __fay(self, index): + if 0 < index < 8: + self.X[0][index] += 1 + # PRED = 1 /(1 + tf.exp(-tf.matmul(tf.constant(self.X,tf.float32), tf.constant(self.W,tf.float32)))) + PRED = np.sum(self.X.reshape(-1) * self.W.reshape(-1)) + if 0 < index < 8: + print('***PRED:{0}***'.format(PRED)) + print(self.X.reshape(-1) * self.W.reshape(-1)) + return PRED + + # 发送情绪 + def __send_mood(self): + while self.__running: + time.sleep(3) + if not self.sleep: + content = {'Topic': 'Unreal', 'Data': {'Key': 'mood', 'Value': self.mood}} + wsa_server.get_instance().add_cmd(content) + + # 更新情绪 + def __update_mood(self, typeIndex): + perception = config_util.config["interact"]["perception"] + if typeIndex == 1: + try: + result = xf_ltp.get_sentiment(self.q_msg) + chat_perception = perception["chat"] + if result == 2: + self.mood = self.mood + (chat_perception / 200.0) + elif result == 0: + self.mood = self.mood - (chat_perception / 100.0) + except BaseException as e: + print("[System] 情绪更新错误!") + print(e) + + elif typeIndex == 2: + self.mood = self.mood + (perception["join"] / 100.0) + + elif typeIndex == 3: + self.mood = self.mood + (perception["gift"] / 100.0) + + elif typeIndex == 4: + self.mood = self.mood + (perception["follow"] / 100.0) + + if self.mood >= 1: + self.mood = 1 + if self.mood <= -1: + self.mood = -1 + + def __get_mood(self): + voice = tts_voice.get_voice_of(config_util.config["attribute"]["voice"]) + if voice is None: + voice = EnumVoice.XIAO_XIAO + styleList = voice.value["styleList"] + sayType = styleList["calm"] + if -1 <= self.mood < -0.5: + sayType = styleList["angry"] + if -0.5 <= self.mood < -0.1: + sayType = styleList["lyrical"] + if -0.1 <= self.mood < 0.1: + sayType = styleList["calm"] + if 0.1 <= self.mood < 0.5: + sayType = styleList["assistant"] + if 0.5 <= self.mood <= 1: + sayType = styleList["cheerful"] + return sayType + + # 合成声音,加上type代表是脚本还是互动 + def __say(self, styleType): + try: + if len(self.a_msg) < 1: + self.speaking = False + else: + # print(self.__get_mood().name + self.a_msg) + util.printInfo(1, '菲菲', '({}) {}'.format(self.__get_mood(), self.a_msg)) + MyThread(target=storer.storage_live_interact, args=[(0, '菲菲', self.a_msg)]).start() + util.log(1, '合成音频...') + tm = time.time() + result = self.sp.to_sample(self.a_msg, self.__get_mood()) + util.log(1, '合成音频完成. 耗时: {} ms'.format(math.floor((time.time() - tm) * 1000))) + if result is not None: + # playsound(result) + # with wave.open(result, 'rb') as wav_file: + # wav_length = wav_file.getnframes() / float(wav_file.getframerate()) + # time.sleep(wav_length) + MyThread(target=self.__send_audio, args=[result, styleType]).start() + # MyThread(target=self.__play_audio, args=[result]).start() + # MyThread(target=self.__waiting_speaking, args=[result]).start() + return result + except BaseException as e: + print(e) + # print("tts失败!!!!!!!!!!!!!") + self.speaking = False + return None + + def __play_sound(self, file_url): + util.log(1, '播放音频...') + util.log(1, '问答处理总时长:{} ms'.format(math.floor((time.time() - self.last_quest_time) * 1000))) + pygame.mixer.music.load(file_url) + pygame.mixer.music.play() + + def __send_audio(self, file_url, say_type): + try: + audio_length = eyed3.load(file_url).info.time_secs + if audio_length <= config_util.config["interact"]["maxInteractTime"] or say_type == "script": + if config_util.config["interact"]["playSound"]: + self.__play_sound(file_url) + else: + content = {'Topic': 'Unreal', 'Data': {'Key': 'audio', 'Value': os.path.abspath(file_url), 'Time': audio_length, 'Type': say_type}} + wsa_server.get_instance().add_cmd(content) + wsa_server.get_web_instance().add_cmd({"panelMsg": self.a_msg}) + time.sleep(audio_length + 0.5) + wsa_server.get_web_instance().add_cmd({"panelMsg": ""}) + if config_util.config["interact"]["playSound"]: + util.log(1, '结束播放!') + self.speaking = False + except Exception as e: + print(e) + + # def __send_audio(self, file_url, say_type): + # try: + # # time.sleep(0.25) + # with wave.open(file_url, 'rb') as wav_file: + # wav_length = wav_file.getnframes() / float(wav_file.getframerate()) + # print(wav_length) + # if wav_length <= config_util.config["interact"]["maxInteractTime"] or say_type == "script": + # if config_util.config["interact"]["playSound"]: + # self.__play_sound(file_url) + # else: + # content = {'Topic': 'Unreal', 'Data': {'Key': 'audio', 'Value': os.path.abspath(file_url), 'Time': wav_length, 'Type': say_type}} + # wsa_server.get_instance().add_cmd(content) + # time.sleep(wav_length + 0.5) + # self.speaking = False + # except Exception as e: + # print(e) + + def __waiting_speaking(self, file_url): + try: + time.sleep(5) + print('[' + str(int(time.time())) + '][菲菲] [S] [开始发言]') + with wave.open(file_url, 'rb') as wav_file: + wav_length = wav_file.getnframes() / float(wav_file.getframerate()) + time.sleep(wav_length) + self.last_interact_time = time.time() + self.speaking = False + print('[' + str(int(time.time())) + '][菲菲] [E] [结束发言]') + time.sleep(30) + os.remove(file_url) + except: + self.last_interact_time = time.time() + self.speaking = False + + # 冷场情绪更新 + def __update_mood_runnable(self): + while self.__running: + time.sleep(10) + update = config_util.config["interact"]["perception"]["indifferent"] / 100 + if len(self.interactive) < 1: + if self.mood > 0: + if self.mood > update: + self.mood = self.mood - update + else: + self.mood = 0 + elif self.mood < 0: + if self.mood < -update: + self.mood = self.mood + update + else: + self.mood = 0 + + def set_sleep(self, sleep): + self.sleep = sleep + + def start(self): + MyThread(target=self.__send_mood).start() + MyThread(target=self.__auto_speak).start() + MyThread(target=self.__update_mood_runnable).start() + + def stop(self): + self.__running = False + self.sp.close() diff --git a/core/recorder.py b/core/recorder.py new file mode 100644 index 0000000..bf78cc8 --- /dev/null +++ b/core/recorder.py @@ -0,0 +1,163 @@ +import audioop +import math +import time +from abc import abstractmethod + +import pyaudio + +from ai_module.ali_nls import ALiNls +from core import wsa_server +from scheduler.thread_manager import MyThread +from utils import util + +# 启动时间 (秒) +_ATTACK = 0.2 + +# 释放时间 (秒) +_RELEASE = 0.75 + + +class Recorder: + + def __init__(self, device, fay): + self.__device = device + self.__fay = fay + + self.__RATE = 16000 + self.__FORMAT = pyaudio.paInt16 + self.__CHANNELS = 1 + + self.__running = True + self.__processing = False + self.__history_level = [] + self.__history_data = [] + self.__dynamic_threshold = 0.5 + + self.__MAX_LEVEL = 25000 + self.__MAX_BLOCK = 100 + + self.__aLiNls = ALiNls() + + def __findInternalRecordingDevice(self, p): + for i in range(p.get_device_count()): + devInfo = p.get_device_info_by_index(i) + if devInfo['name'].find(self.__device) >= 0 and devInfo['hostApi'] == 0: + return i + util.log(1, '[!] 无法找到内录设备!') + return -1 + + def __get_history_average(self, number): + total = 0 + num = 0 + for i in range(len(self.__history_level) - 1, -1, -1): + level = self.__history_level[i] + total += level + num += 1 + if num >= number: + break + return total / num + + def __get_history_percentage(self, number): + return (self.__get_history_average(number) / self.__MAX_LEVEL) * 1.05 + 0.02 + + def __print_level(self, level): + text = "" + per = level / self.__MAX_LEVEL + if per > 1: + per = 1 + bs = int(per * self.__MAX_BLOCK) + for i in range(bs): + text += "#" + for i in range(self.__MAX_BLOCK - bs): + text += "-" + print(text + " [" + str(int(per * 100)) + "%]") + + def __waitingResult(self, iat: ALiNls): + self.processing = True + t = time.time() + tm = time.time() + # 等待结果返回 + while not iat.done and time.time() - t < 1: + time.sleep(0.01) + text = iat.finalResults + util.log(1, "语音处理完成! 耗时: {} ms".format(math.floor((time.time() - tm) * 1000))) + if len(text) > 0: + self.on_speaking(text) + self.processing = False + else: + util.log(1, "[!] 语音未检测到内容!") + self.processing = False + self.dynamic_threshold = self.__get_history_percentage(30) + wsa_server.get_web_instance().add_cmd({"panelMsg": ""}) + + def __record(self): + p = pyaudio.PyAudio() + device_id = self.__findInternalRecordingDevice(p) + if device_id < 0: + return + stream = p.open(input_device_index=device_id, rate=self.__RATE, format=self.__FORMAT, channels=self.__CHANNELS, input=True) + + isSpeaking = False + last_mute_time = time.time() + last_speaking_time = time.time() + while self.__running: + data = stream.read(1024) + level = audioop.rms(data, 2) + if len(self.__history_data) >= 5: + self.__history_data.pop(0) + if len(self.__history_level) >= 500: + self.__history_level.pop(0) + self.__history_data.append(data) + self.__history_level.append(level) + + percentage = level / self.__MAX_LEVEL + history_percentage = self.__get_history_percentage(30) + + if history_percentage > self.__dynamic_threshold: + self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 0.0025 + elif history_percentage < self.__dynamic_threshold: + self.__dynamic_threshold += (history_percentage - self.__dynamic_threshold) * 1 + + soon = False + if percentage > self.__dynamic_threshold and not self.__fay.speaking: + last_speaking_time = time.time() + if not self.__processing and not isSpeaking and time.time() - last_mute_time > _ATTACK: + soon = True + isSpeaking = True + util.log(3, "聆听中...") + self.__aLiNls = ALiNls() + try: + self.__aLiNls.start() + except Exception as e: + print(e) + for buf in self.__history_data: + self.__aLiNls.send(buf) + else: + last_mute_time = time.time() + if isSpeaking: + if time.time() - last_speaking_time > _RELEASE: + isSpeaking = False + self.__aLiNls.end() + util.log(1, "语音处理中...") + self.__fay.last_quest_time = time.time() + self.__waitingResult(self.__aLiNls) + if not soon and isSpeaking: + self.__aLiNls.send(data) + + stream.stop_stream() + stream.close() + p.terminate() + + def set_processing(self, processing): + self.__processing = processing + + def start(self): + MyThread(target=self.__record).start() + + def stop(self): + self.__running = False + self.__aLiNls.end() + + @abstractmethod + def on_speaking(self, text): + pass diff --git a/core/tts_voice.py b/core/tts_voice.py new file mode 100644 index 0000000..76cc11a --- /dev/null +++ b/core/tts_voice.py @@ -0,0 +1,37 @@ +from enum import Enum + + +class EnumVoice(Enum): + XIAO_XIAO = { + "name": "晓晓", + "voiceName": "zh-CN-XiaoxiaoNeural", + "styleList": { + "angry": "angry", + "lyrical": "lyrical", + "calm": "gentle", + "assistant": "affectionate", + "cheerful": "cheerful" + } + } + YUN_XI = { + "name": "云溪", + "voiceName": "zh-CN-YunxiNeural", + "styleList": { + "angry": "angry", + "lyrical": "disgruntled", + "calm": "calm", + "assistant": "assistant", + "cheerful": "cheerful" + } + } + + +def get_voice_list(): + return [EnumVoice.YUN_XI, EnumVoice.XIAO_XIAO] + + +def get_voice_of(name): + for voice in get_voice_list(): + if voice.name == name: + return voice + return None diff --git a/core/viewer.py b/core/viewer.py new file mode 100644 index 0000000..571ace9 --- /dev/null +++ b/core/viewer.py @@ -0,0 +1,290 @@ +from abc import abstractmethod +import json +import random +import time +import requests +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support.expected_conditions import presence_of_element_located + +from scheduler.thread_manager import MyThread +from utils import config_util, util + +USER_URL = 'https://www.douyin.com/user/' + + +class Viewer: + + def __init__(self, url): + self.url = url + self.GIFT_TYPES = { + '0ea40b8376ef8157791b928a339ed9c9': (1, '小星星', 1), + 'a29d6cdc0abb7286fdd403915196eaa7': (2, '玫瑰', 1), + '802a21ae29f9fae5abe3693de9f874bd': (3, '抖音', 1), + 'a24b3cc863742fd4bc3de0f53dac4487': (4, '大啤酒', 2), + '4960c39f645d524beda5d50dc372510e': (5, '你最好看', 2), + 'e9b7db267d0501b8963d8000c091e123': (6, '人气票', 1), + '698373dfdac86a90b54facdc38698cbc': (7, '粉丝团灯牌', 1) + } + self.__running = True + self.live_driver = None + self.user_driver = None + self.user_sec_uid = None + self.last_join_data = '' + self.last_interact_datas = [] + self.live_started = False + self.last_chat_item_index = 0 + + def __start(self): + MyThread(target=self.__driver_alive_runnable).start() + self.chrome_options = Options() + self.chrome_options.add_argument('--headless') + self.chrome_options.add_argument('--blink-settings=imagesEnabled=false') + self.live_driver = webdriver.Chrome(config_util.system_chrome_driver, options=self.chrome_options) + self.live_driver.get(self.url) + self.user_driver = webdriver.Chrome(config_util.system_chrome_driver, options=self.chrome_options) + self.__wait_live_start() + self.user_sec_uid = self.__get_render_data(self.live_driver)['initialState']['roomStore']['roomInfo']['room']['owner']['sec_uid'] + MyThread(target=self.__live_state_runnable).start() + MyThread(target=self.__join_runnable).start() + MyThread(target=self.__interact_runnable).start() + MyThread(target=self.__follower_runnable).start() + + def start(self): + MyThread(target=self.__start).start() + + def is_live_started(self): + return self.live_started + + def __wait_live_start(self): + if self.__is_live(): + return + util.log(1, '等待直播开始...') + time.sleep(30) + while not self.__is_live() and self.__running: + try: + self.live_driver.get(self.url) + except: + pass + time.sleep(30) + + def __is_live(self): + try: + xpath = '//*[@id="_douyin_live_scroll_container_"]/div/div[2]/div/div[2]/div/div[2]/div' + element = self.live_driver.find_element_by_xpath(xpath) + return '结束' not in element.text + except BaseException as e: + print(e) + return False + + def __driver_alive_runnable(self): + while self.__running: + time.sleep(0.1) + try: + if self.live_driver is not None: + try: + self.live_driver.execute_script('javascript:void(0);') + except: + if self.__running: + self.live_driver = webdriver.Chrome(config_util.system_chrome_driver, options=self.chrome_options) + self.live_driver.get(self.url) + if self.user_driver is not None: + try: + self.user_driver.execute_script('javascript:void(0);') + except: + if self.__running: + self.user_driver = webdriver.Chrome(config_util.system_chrome_driver, options=self.chrome_options) + except: + pass + + def __live_state_runnable(self): + while self.__running: + is_live = self.__is_live() + if is_live != self.live_started: + self.live_started = self.__is_live() + self.on_change_state(is_live) + if not is_live: + util.log(1, '直播直播已结束,等待下场直播开始...') + if is_live != True: + try: + self.live_driver.get(self.url) + except: + pass + time.sleep(30) + + def __get_render_data(self, driver): + wait = WebDriverWait(driver, 10) + first_result = wait.until(presence_of_element_located((By.ID, "RENDER_DATA"))) + return json.loads(requests.utils.unquote(first_result.get_attribute("textContent"))) + + def __get_interact_type(self, text): + ary = text.split(':') + if len(ary) >= 2: + content_ary = ary[1].split(' ') + if len(content_ary) == 3 and content_ary[0] == '送出了': + return 3 + return 1 + + def __get_gift_type(self, url): + for gift_id in self.GIFT_TYPES.keys(): + if gift_id in url: + return self.GIFT_TYPES.get(gift_id) + return -1, '其他礼物', 0 + + def __get_join_data(self): + try: + xpath = '//*[@id="_douyin_live_scroll_container_"]/div/div[2]/div/div[2]/div/div[1]/div/div/div/div[1]/div/div[2]' + element = self.live_driver.find_element_by_xpath(xpath) + ary = element.text.split('\n') + text = ary[len(ary) - 1] + if len(text) > 0 and self.last_join_data != text: + self.last_join_data = text + user = text[0:len(text) - 3] + return 2, user, '来了' + except BaseException as e: + return None + return None + + def __get_interact_data(self): + interact_data = [] + chatroom_xpath = '//*[@id="_douyin_live_scroll_container_"]/div/div[2]/div/div[2]/div/div[1]/div/div/div/div[1]/div/div[1]' + try: + chatroom_element = self.live_driver.find_element_by_xpath(chatroom_xpath) + + index_range = None + + if self.last_chat_item_index < 100: + start = self.last_chat_item_index + 1 + if start < 1: + start = 1 + index_range = range(start, 101) # 升序 + else: + index_range = range(100, 0, -1) # 降序 + + # print("\n上一次: {}".format(self.last_chat_item_index)) + for index in index_range: + + # print("到了: {}".format(index)) + chatroom_item = None + try: + chatroom_item = chatroom_element.find_element_by_xpath(chatroom_xpath + '/div[' + str(index) + ']') + except: + pass + + item_id = None + if self.last_chat_item_index < 100: + if chatroom_item is None: + self.last_chat_item_index = index - 1 + break + elif index >= 100: + self.last_chat_item_index = index + else: + if chatroom_item is None: + continue + item_id = chatroom_item.id + if item_id in self.last_interact_datas: + break + + # print(index) + + if len(self.last_interact_datas) > 200: + self.last_interact_datas.pop(0) + + self.last_interact_datas.append(item_id) + item_text = chatroom_item.text + ary = chatroom_item.text.replace('\r', '').split('\n') + text = ary[len(ary) - 1] + if len(text) < 1 and len(ary) > 1: + text = ary[len(ary) - 2] + speak = self.__get_speak(text) + if speak is None: + # print("无法分析[O]: " + item_text) + # print("无法分析[R]: " + text) + continue + if self.__get_interact_type(text) == 3: + item_msg = None + try: + item_msg = chatroom_element.find_element_by_xpath( + chatroom_xpath + '/div[' + str(index) + ']/div/span[3]/span/span/img') + except: + continue + gift = self.__get_gift_type(item_msg.get_attribute('src')) + arg = speak[1].split(' ') + amount = int(arg[len(arg) - 1]) # 礼物数量 + interact_data.append((3, speak[0], ('送出了 {0} X {1}'.format(gift[1], amount)), gift, amount)) + else: + interact_data.append((1, speak[0], speak[1])) + except BaseException as e: + interact_data.reverse() + return interact_data + interact_data.reverse() + return interact_data + + def __get_speak(self, text): + ary = text.split(':') + if len(ary) < 2: + return None + user = ary[0] + speak = text[len(ary[0]) + 1:] + if len(user) > 0 and len(speak) > 0: + return user, speak + + def __join_runnable(self): + while self.__running: + if not self.live_started: + continue + # 进入 抓取 + join_data = self.__get_join_data() + if join_data is not None: + self.on_interact(join_data, time.time()) + time.sleep(0.05) + + def __interact_runnable(self): + while self.__running: + if not self.live_started: + continue + # 发言 & 刷礼物 抓取 + for interact in self.__get_interact_data(): + MyThread(target=self.on_interact, args=[interact, time.time()]).start() + # self.on_interact(interact, time.time()) + + def __follower_runnable(self): + followers = -1 + while self.__running: + # 关注 抓取 + try: + time.sleep(1.0 + random.random()) + self.user_driver.get(USER_URL + self.user_sec_uid) + time.sleep(0.2) + render_data = self.__get_render_data(self.user_driver) + fs = -1 + for i in range(100, -1, -1): + if str(i) in render_data and 'user' in render_data[str(i)] and 'user' in render_data[str(i)]['user'] and 'followerCount' in render_data[str(i)]['user']['user']: + fs = int(render_data[str(i)]['user']['user']['followerCount']) + break + if fs >= 0: + if self.live_started and 0 < followers < fs: + self.on_interact((4, 'None', '粉丝关注'), time.time()) + followers = fs + else: + util.log(1, '粉丝数获取异常') + except BaseException as e: + util.log(1, e) + util.log(1, '粉丝数获取异常') + + def stop(self): + self.__running = False + if self.live_driver: + self.live_driver.quit() + if self.user_driver: + self.user_driver.quit() + + @abstractmethod + def on_interact(self, interact, event_time): + pass + + @abstractmethod + def on_change_state(self, is_live_started): + pass diff --git a/core/wsa_server.py b/core/wsa_server.py new file mode 100644 index 0000000..e4e1d3f --- /dev/null +++ b/core/wsa_server.py @@ -0,0 +1,123 @@ +from asyncio import AbstractEventLoop + +import websockets +import asyncio +import json + +from websockets.legacy.server import Serve + +from scheduler.thread_manager import MyThread + + +class MyServer: + def __init__(self, host='127.0.0.1', port=10000): + self.__host = host # ip + self.__port = port # 端口号 + self.__listCmd = [] # 要发送的信息的列表 + self.__server: Serve = None + self.__message_value = None # client返回消息的value + self.__event_loop: AbstractEventLoop = None + self.__running = True + self.__pending = None + + def __del__(self): + self.stop_server() + + async def __consumer_handler(self, websocket, path): + async for message in websocket: + await self.__consumer(message) + + async def __producer_handler(self, websocket, path): + while self.__running: + await asyncio.sleep(0.000001) + message = await self.__producer() + if message: + await websocket.send(message) + # util.log('发送 {}'.format(message)) + + async def __handler(self, websocket, path): + consumer_task = asyncio.ensure_future(self.__consumer_handler(websocket, path)) + producer_task = asyncio.ensure_future(self.__producer_handler(websocket, path)) + done, self.__pending = await asyncio.wait([consumer_task, producer_task], return_when=asyncio.FIRST_COMPLETED, ) + for task in self.__pending: + task.cancel() + + # 接收处理 + async def __consumer(self, message): + pass + # print('recv message: {0}'.format(message)) + + # 发送处理 + async def __producer(self): + if len(self.__listCmd) > 0: + return self.__listCmd.pop(0) + else: + return None + + # 创建server + def __connect(self): + self.__event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.__event_loop) + self.__isExecute = True + if self.__server: + print('server already exist') + return + self.__server = websockets.serve(self.__handler, self.__host, self.__port) + asyncio.get_event_loop().run_until_complete(self.__server) + asyncio.get_event_loop().run_forever() + + # 往要发送的命令列表中,添加命令 + def add_cmd(self, content): + if not self.__running: + return + jsonObj = json.dumps(content) + self.__listCmd.append(jsonObj) + # util.log('命令 {}'.format(content)) + + # 开启服务 + def start_server(self): + MyThread(target=self.__connect).start() + + # 关闭服务 + def stop_server(self): + self.__running = False + if self.__server is None: + return + self.__server.ws_server.close() + self.__server = None + try: + all_tasks = asyncio.all_tasks(self.__event_loop) + for task in all_tasks: + # print(task.cancel()) + while not task.cancel(): + print("无法关闭!") + self.__event_loop.stop() + self.__event_loop.close() + except BaseException as e: + print("Error: {}".format(e)) + + +__instance: MyServer = None +__web_instance: MyServer = None + + +def new_instance(host='127.0.0.1', port=10000) -> MyServer: + global __instance + if __instance is None: + __instance = MyServer(host, port) + return __instance + + +def new_web_instance(host='127.0.0.1', port=10000) -> MyServer: + global __web_instance + if __web_instance is None: + __web_instance = MyServer(host, port) + return __web_instance + + +def get_instance() -> MyServer: + return __instance + + +def get_web_instance() -> MyServer: + return __web_instance diff --git a/fay_booter.py b/fay_booter.py new file mode 100644 index 0000000..4b11c95 --- /dev/null +++ b/fay_booter.py @@ -0,0 +1,170 @@ +import time + +from core.recorder import Recorder +from core.fay_core import FeiFei +from core.viewer import Viewer +from scheduler.thread_manager import MyThread +from utils import util, config_util + +feiFei: FeiFei = None +viewerListener: Viewer = None +recorderListener: Recorder = None + +__running = True + + +class ViewerListener(Viewer): + + def __init__(self, url): + super().__init__(url) + + def on_interact(self, interact, event_time): + type_names = { + 1: '发言', + 2: '进入', + 3: '送礼', + 4: '关注' + } + util.printInfo(1, type_names[interact[0]], '{}: {}'.format(interact[1], interact[2]), event_time) + if interact[0] == 1: + feiFei.last_quest_time = time.time() + thr = MyThread(target=feiFei.on_interact, args=[interact]) + thr.start() + thr.join() + + def on_change_state(self, is_live_started): + feiFei.set_sleep(not is_live_started) + pass + + +class RecorderListener(Recorder): + + def __init__(self, device, fei): + super().__init__(device, fei) + + def on_speaking(self, text): + interact = (1, '', text) + util.printInfo(3, "语音", '{}'.format(interact[2]), time.time()) + feiFei.on_interact(interact) + time.sleep(2) + + +def console_listener(): + type_names = { + 1: '发言', + 2: '进入', + 3: '送礼', + 4: '关注' + } + while __running: + text = input() + args = text.split(' ') + + if len(args) == 0 or len(args[0]) == 0: + continue + + if args[0] == 'help': + util.log(1, 'in \t通过控制台交互') + util.log(1, 'restart \t重启服务') + util.log(1, 'stop \t\t关闭服务') + + elif args[0] == 'stop': + stop() + break + + elif args[0] == 'restart': + stop() + time.sleep(0.1) + start() + + elif args[0] == 'in': + if len(args) == 1: + util.log(1, '错误的参数!') + msg = text[3:len(text)] + i = 1 + try: + i = int(msg) + except: + pass + if i < 1: + i = 1 + if i > 4: + i = 4 + util.printInfo(1, type_names[i], '{}: {}'.format('控制台', msg)) + if i == 1: + feiFei.last_quest_time = time.time() + thr = MyThread(target=feiFei.on_interact, args=[(i, '', msg)]) + thr.start() + thr.join() + + else: + util.log(1, '未知命令!使用 \'help\' 获取帮助.') + + +def stop(): + global feiFei + global viewerListener + global recorderListener + global __running + + util.log(1, '正在关闭服务...') + __running = False + # util.log('正在关闭通讯服务...') + # wsa_server.get_instance().stop_server() + if viewerListener is not None: + util.log(1, '正在关闭直播服务...') + viewerListener.stop() + if recorderListener is not None: + util.log(1, '正在关闭录音服务...') + recorderListener.stop() + util.log(1, '正在关闭核心服务...') + feiFei.stop() + util.log(1, '服务已关闭!') + + +def start(): + # global ws_server + global feiFei + global viewerListener + global recorderListener + global __running + + util.log(1, '开启服务...') + __running = True + util.log(1, '读取配置...') + config_util.load_config() + # + # util.log('开启通讯服务...') + # ws_server = MyServer() + # ws_server.start_server() + + util.log(1, '开启核心服务...') + feiFei = FeiFei() + feiFei.start() + + liveRoom = config_util.config['source']['liveRoom'] + record = config_util.config['source']['record'] + + if liveRoom['enabled']: + util.log(1, '开启直播服务...') + viewerListener = ViewerListener(liveRoom['url']) # 监听直播间 + viewerListener.start() + + if record['enabled']: + util.log(1, '开启录音服务...') + recorderListener = RecorderListener(record['device'], feiFei) # 监听麦克风 + recorderListener.start() + + util.log(1, '注册命令...') + MyThread(target=console_listener).start() # 监听控制台 + + util.log(1, '完成!') + util.log(1, '使用 \'help\' 获取帮助.') + +# if __name__ == '__main__': +# ws_server: MyServer = None +# feiFei: FeiFei = None +# viewerListener: Viewer = None +# recorderListener: Recorder = None +# start() +# config_util.save_config() diff --git a/gui/flask_server.py b/gui/flask_server.py new file mode 100644 index 0000000..808dbe3 --- /dev/null +++ b/gui/flask_server.py @@ -0,0 +1,82 @@ +import json +import time + +import pyaudio +from flask import Flask, render_template, request +from flask_cors import CORS + +import fay_booter +from core import wsa_server +from core.tts_voice import EnumVoice +from scheduler.thread_manager import MyThread +from utils import config_util + +__app = Flask(__name__) +CORS(__app, supports_credentials=True) + + +def __get_template(): + return render_template('index.html') + + +def __get_device_list(): + audio = pyaudio.PyAudio() + device_list = [] + for i in range(audio.get_device_count()): + devInfo = audio.get_device_info_by_index(i) + if devInfo['hostApi'] == 0: + device_list.append(devInfo["name"]) + return device_list + + +@__app.route('/api/submit', methods=['post']) +def api_submit(): + data = request.values.get('data') + # print(data) + config_data = json.loads(data) + config_util.save_config(config_data['config']) + return '{"result":"successful"}' + + +@__app.route('/api/get-data', methods=['post']) +def api_get_data(): + wsa_server.get_web_instance().add_cmd({ + "voiceList": [ + {"id": EnumVoice.XIAO_XIAO.name, "name": "晓晓"}, + {"id": EnumVoice.YUN_XI.name, "name": "云溪"} + ] + }) + wsa_server.get_web_instance().add_cmd({"deviceList": __get_device_list()}) + return json.dumps({'config': config_util.config}) + + +@__app.route('/api/start-live', methods=['post']) +def api_start_live(): + # time.sleep(5) + fay_booter.start() + time.sleep(1) + wsa_server.get_web_instance().add_cmd({"liveState": 1}) + return '{"result":"successful"}' + + +@__app.route('/api/stop-live', methods=['post']) +def api_stop_live(): + # time.sleep(1) + fay_booter.stop() + time.sleep(1) + wsa_server.get_web_instance().add_cmd({"liveState": 0}) + return '{"result":"successful"}' + + +@__app.route('/', methods=['get']) +def home_get(): + return __get_template() + + +@__app.route('/', methods=['post']) +def home_post(): + return __get_template() + + +def start(): + MyThread(target=__app.run).start() diff --git a/gui/static/css/index.css b/gui/static/css/index.css new file mode 100644 index 0000000..594b9f6 --- /dev/null +++ b/gui/static/css/index.css @@ -0,0 +1,261 @@ +#app { + width: 1920px; + height: 1080px; + margin: 0; + padding: 0; +} + +ul { + list-style-type: none; +} + +.main { + width: 1920px; + height: 1080px; + display: flex; + flex-direction: column; + /* flex-wrap: wrap; */ +} + +.main_box { + width: 100%; + display: flex; + +} + +.left { + width: 915px; + margin-left: 15px; +} + +.left .left_top { + width: 915px; + border: 1px solid #333333; + +} + +.left_top_p { + padding-left: 15px; +} + +.character { + display: flex; + flex-direction: column; + flex-wrap: wrap; +} + +.character_top { + width: 100%; + display: flex; +} + +.character_left { + width: 443px; + display: flex; +} + +.character_left ul {} + +.character_left ul li { + display: flex; + height: 51.5px; +} + +.character_left ul li p { + width: 100px; + text-align: right; + margin-top: 5px; +} + +.character_left ul li .el-input { + width: 320px; + height: 45px; +} + +.character_right { + display: flex; + width: 430px; +} + +.character_right ul { + width: 430px; +} + +.character_right ul li { + display: flex; + width: 430px; +} + +.character_right ul li p { + width: 120px; + text-align: right; + margin-top: 5px; + +} + +.character_right ul li .el-slider__runway { + width: 250px; +} +.character_right ul li .el-select { + display: inline-block; + position: relative; + width: 250px; +} +.character_box { + width: 100%; + display: flex; + margin-left: 40px; +} + +.character_box p { + width: 100px; +} + +.character_box .el-input { + width: 730px; +} + + +.title { + width: 100%; + height: 75px; +} + +.title h2 { + width: 100%; + height: 75px; + text-align: center; +} + +.left_box { + width: 915px; + /*height: 260px;*/ + margin-top: 15px; + border: 1px solid #333333; +} + +.left_box p { + padding-left: 15px; +} + +.left_box .source {} + +.left_box .source ul {} + +.left_box .source ul li {} + +.left_box .source ul .url { + width: 750px; + margin: 20px auto 0; + height: 40px; + display: flex; +} + +.left_box .source ul .url .el-switch { + position: relative; + top: 8px; +} + +.left_box .source ul .url p { + width: 85px; + height: 40px; + text-align: center; + line-height: 0; +} + +.left_box .source ul .url .el-input { + height: 40px; +} +.left_box .source ul .url .el-select { + height: 40px; + width: 750px; +} + +.left_box .source ul .but { + width: 750px; + display: flex; + justify-content: center; + margin: auto; +} + +.left_box .source ul .but .el-button { + margin: 20px auto 0; +} + +.left_box .source ul .p_red { + width: 750px; + display: flex; + justify-content: center; + margin: auto; +} + +.left_box .source ul .p_red p { + color: red; + +} + +.right { + width: 915px; + margin-left: 15px; +} + +.right_main { + width: 915px; + border: 1px solid #333333; +} + +.right_main ul { + width: 915px; +} + +.right_main ul li { + width: 915px; + display: flex; + padding-top: 10px; + padding-bottom: 10px; +} + +.right_main ul li p { + width: 128px; + text-align: right; + padding: 0; + margin: 0; +} + +.right_main ul li .el-input { + width: 666px; +} + +.right_main ul li .upload-demo { + width: 666px; +} +.right_main ul li .el-textarea { + width: 666px; +} + +.right_main ul li .el-switch { + position: relative; + top: 2px; +} +.el-input__inner { + -webkit-appearance: none; + background-color: #FFF; + border-radius: 4px; + border: 1px solid #DCDFE6; + box-sizing: border-box; + color: #606266; + display: inline-block; + font-size: inherit; + height: 43px; + line-height: 40px; + outline: 0; + padding: 0 15px; + transition: border-color .2s cubic-bezier(.645,.045,.355,1); + width: 100%; +} +.el-input.is-disabled .el-input__inner { + background-color: #F5F7FA; + border-color: #E4E7ED; + color: #000206 !important; + cursor: not-allowed; +} \ No newline at end of file diff --git a/gui/static/js/index.js b/gui/static/js/index.js new file mode 100644 index 0000000..9d46d70 --- /dev/null +++ b/gui/static/js/index.js @@ -0,0 +1,447 @@ +new Vue({ + el: '#app', + data() { + return { + testlist: [ + { + tab_name: "first", + name: "first", + }, + { + tab_name: "2", + name: "2", + }, + { + tab_name: "3", + name: "3", + } + ], + fileList: {}, + panel_msg: "", + play_sound_enabled: false, + source_liveRoom_enabled: false, + source_liveRoom_url: '', + source_record_enabled: false, + source_record_device: '', + attribute_name: "", + attribute_gender: "", + attribute_age: "", + attribute_birth: "", + attribute_zodiac: "", + attribute_constellation: "", + attribute_job: "", + attribute_hobby: "", + attribute_contact: "", + attribute_voice: "", + interact_perception_gift: 0, + interact_perception_follow: 0, + interact_perception_join: 0, + interact_perception_chat: 0, + interact_perception_indifferent: 0, + interact_maxInteractTime: 15, + interact_QnA: "", + items_data: [], + live_state: 0, + device_list: [], + // device_list: [ + // { + // value: '选项1', + // label: '麦克风' + // } + // ], + voice_list: [], + options: [{ + value: '选项1', + label: '黄金糕' + }, { + value: '选项2', + label: '双皮奶' + }], + activeName: 'first', + + editableTabsValue: '1', + tabIndex: 1, + editableTabs: [{ + title: 'Tab 1', + name: '1', + content: 'Tab 1 content' + }, { + title: 'Tab 2', + name: '2', + content: 'Tab 2 content' + }], + + } + }, + methods: { + handleTabsEdit(targetName, action) { + if (action === 'add') { + let newTabName = ++this.tabIndex + ''; + this.items_data.push({ + tab_name: newTabName, + enabled: false, + name: "", + explain: { + intro: "", + usage: "", + price: "", + discount: "", + promise: "", + character: "" + }, + demoVideo: "", + QnA: "" + }); + this.editableTabsValue = newTabName; + } + if (action === 'remove') { + let tabs = this.items_data; + let activeName = this.editableTabsValue; + if (activeName === targetName) { + tabs.forEach((tab, index) => { + if (tab.tab_name === targetName) { + let nextTab = tabs[index + 1] || tabs[index - 1]; + if (nextTab) { + activeName = nextTab.name; + } + } + }); + } + this.editableTabsValue = activeName; + this.items_data = tabs.filter(tab => tab.tab_name !== targetName); + } + }, + show() { + alert("run...") + }, + formatTooltip(val) { + return val / 100; + }, + handleChange(value) { + console.log(value); + }, + handleClick(tab, event) { + console.log(tab, event); + }, + handleRemove(file, fileList) { + console.log(file, fileList); + }, + handlePreview(file) { + console.log(file); + }, + onExceed() { + }, + beforeRemove() { + }, + handleExceed() { + }, + connectWS() { + let _this = this; + let socket = new WebSocket('ws://localhost:10003') + socket.onopen = function () { + // console.log('客户端连接上了服务器'); + } + socket.onmessage = function (e) { + // console.log(" --> " + e.data) + let data = JSON.parse(e.data) + _this.live_broadcast = (data.time % 2) === 0 + let liveState = data.liveState + if (liveState !== undefined) { + _this.live_state = liveState + if (liveState === 1) { + _this.sendSuccessMsg("已开启!") + } else if (liveState === 0) { + _this.sendSuccessMsg("已关闭!") + } + } + let voiceList = data.voiceList + if (voiceList !== undefined) { + voice_list = [] + for (let i = 0; i < voiceList.length; i++) { + voice_list[i] = { + value: voiceList[i].id, + label: voiceList[i].name + } + _this.voice_list = voice_list + } + } + + let deviceList = data.deviceList + if (deviceList !== undefined) { + device_list = [] + for (let i = 0; i < deviceList.length; i++) { + device_list[i] = { + value: deviceList[i], + label: deviceList[i] + } + _this.device_list = device_list + } + } + let panelMsg = data.panelMsg + if (panelMsg !== undefined) { + _this.panel_msg = panelMsg + } + } + }, + getData() { + let _this = this; + let url = "http://127.0.0.1:5000/api/get-data"; + let xhr = new XMLHttpRequest() + xhr.open("post", url) + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded") + xhr.send() + let executed = false + xhr.onreadystatechange = async function () { + if (!executed && xhr.status === 200) { + try { + if (xhr.responseText.length > 0) { + let data = await eval('(' + xhr.responseText + ')') + let config = data["config"] + let source = config["source"] + let attribute = config["attribute"] + let interact = config["interact"] + let perception = interact["perception"] + let items = config["items"] + _this.play_sound_enabled = interact["playSound"] + _this.source_liveRoom_enabled = source["liveRoom"]["enabled"] + _this.source_liveRoom_url = source["liveRoom"]["url"] + _this.source_record_enabled = source["record"]["enabled"] + _this.source_record_device = source["record"]["device"] + _this.attribute_name = attribute["name"] + _this.attribute_gender = attribute["gender"] + _this.attribute_age = attribute["age"] + _this.attribute_birth = attribute["birth"] + _this.attribute_zodiac = attribute["zodiac"] + _this.attribute_constellation = attribute["constellation"] + _this.attribute_job = attribute["job"] + _this.attribute_hobby = attribute["hobby"] + _this.attribute_contact = attribute["contact"] + _this.attribute_voice = attribute["voice"] + _this.interact_perception_gift = parseInt(perception["gift"]) + _this.interact_perception_follow = perception["follow"] + _this.interact_perception_join = perception["join"] + _this.interact_perception_chat = perception["chat"] + _this.interact_perception_indifferent = perception["indifferent"] + _this.interact_maxInteractTime = interact["maxInteractTime"] + _this.interact_QnA = interact["QnA"] + let item_data_list = [] + for (let i = 0; i < items.length; i++) { + let item = items[i] + let _tab_name = "first" + if (i > 0) { + _tab_name = i.toString() + } + item_data_list[i] = { + tab_name: _tab_name, + enabled: item.enabled, + name: item.name, + explain: { + intro: item.explain.intro, + usage: item.explain.usage, + price: item.explain.price, + discount: item.explain.discount, + promise: item.explain.promise, + character: item.explain.character + }, + demoVideo: item.demoVideo, + QnA: item.QnA + } + } + _this.items_data = item_data_list + console.log(_this.items_data); + executed = true + } + } catch (e) { + console.log(e); + } + } + } + }, + postData() { + let url = "http://127.0.0.1:5000/api/submit"; + let send_data = { + "config": { + "source": { + "liveRoom": { + "enabled": this.source_liveRoom_enabled, + "url": this.source_liveRoom_url + }, + "record": { + "enabled": this.source_record_enabled, + "device": this.source_record_device + } + }, + "attribute": { + "voice": this.attribute_voice, + "name": this.attribute_name, + "gender": this.attribute_gender, + "age": this.attribute_age, + "birth": this.attribute_birth, + "zodiac": this.attribute_zodiac, + "constellation": this.attribute_constellation, + "job": this.attribute_job, + "hobby": this.attribute_hobby, + "contact": this.attribute_contact + }, + "interact": { + "playSound": this.play_sound_enabled, + "QnA": this.interact_QnA, + "maxInteractTime": this.interact_maxInteractTime, + "perception": { + "gift": this.interact_perception_gift, + "follow": this.interact_perception_follow, + "join": this.interact_perception_join, + "chat": this.interact_perception_chat, + "indifferent": this.interact_perception_indifferent + } + }, + "items": [], + } + }; + for (let i = 0; i < this.items_data.length; i++) { + let item = this.items_data[i] + send_data.config.items[i] = { + enabled: item.enabled, + name: item.name, + explain: { + intro: item.explain.intro, + usage: item.explain.usage, + price: item.explain.price, + discount: item.explain.discount, + promise: item.explain.promise, + character: item.explain.character + }, + demoVideo: item.demoVideo, + QnA: item.QnA + } + } + let xhr = new XMLHttpRequest() + xhr.open("post", url) + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded") + xhr.send('data=' + JSON.stringify(send_data)) + let executed = false + xhr.onreadystatechange = async function () { + if (!executed && xhr.status === 200) { + try { + let data = await eval('(' + xhr.responseText + ')') + console.log("data: " + data['result']) + executed = true + } catch (e) { + } + } + } + this.sendSuccessMsg("配置已保存!") + }, + postStartLive() { + this.postData() + this.live_state = 2 + let url = "http://127.0.0.1:5000/api/start-live"; + let xhr = new XMLHttpRequest() + xhr.open("post", url) + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded") + xhr.send() + }, + postStopLive() { + this.live_state = 3 + let url = "http://127.0.0.1:5000/api/stop-live"; + let xhr = new XMLHttpRequest() + xhr.open("post", url) + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded") + xhr.send() + }, + isEmptyItem(data) { + let isEmpty = true + let explain = data["explain"] + for (let key in data) { + let value = data[key] + if (key !== "tab_name" && value.constructor === String && value.length > 0) { + isEmpty = false + break + } + } + for (let key in explain) { + let value = explain[key] + if (value.constructor === String && value.length > 0) { + isEmpty = false + break + } + } + return isEmpty + }, + lastItemIsEmpty() { + return this.isEmptyItem(this.items_data[this.items_data.length - 1]) + }, + uuid() { + let s = [] + let hexDigits = '0123456789abcdef' + for (let i = 0; i < 36; i++) { + s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1) + } + s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010 + s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01 + s[8] = s[13] = s[18] = s[23] = '-' + + let uuid = s.join('') + return uuid + }, + runnnable() { + setTimeout(() => { + let _this = this + let item_data_list = [] + let changed = false + let index = 0 + for (let i = 0; i < _this.items_data.length; i++) { + let data = _this.items_data[i] + if (i === (_this.items_data.length - 1) || !this.isEmptyItem(data)) { + item_data_list[index] = _this.items_data[i] + index++ + } else { + changed = true + } + } + if (!this.lastItemIsEmpty()) { + changed = true + item_data_list.push({ + tab_name: this.uuid(), + enabled: false, + name: "", + explain: { + intro: "", + usage: "", + price: "", + discount: "", + promise: "", + character: "" + }, + demoVideo: "", + QnA: "" + }) + } + if (changed) { + _this.items_data = item_data_list + console.log("修改了!" + _this.items_data.length) + } + this.runnnable() + }, 50) + }, + sendSuccessMsg(text) { + this.$notify({ + title: '成功', + message: text, + type: 'success' + }); + }, + }, + mounted() { + let _this = this; + _this.getData(); + _this.connectWS() + // _this.runnnable() + // _this.items_data.push({}); + }, + watch: { + items_data() { + // console.log("items_data 改变了"); + } + } +}) \ No newline at end of file diff --git a/gui/static/js/self-adaption.js b/gui/static/js/self-adaption.js new file mode 100644 index 0000000..e730f62 --- /dev/null +++ b/gui/static/js/self-adaption.js @@ -0,0 +1,25 @@ +window.onload = function () { + document.body.style.zoom = "normal";//避免zoom尺寸叠加 + let scale = document.body.clientWidth / 1920; + document.body.style.zoom = scale; +}; (function () { + var throttle = function (type, name, obj) { + obj = obj || window; + var running = false; + var func = function () { + if (running) { return; } + running = true; + requestAnimationFrame(function () { + obj.dispatchEvent(new CustomEvent(name)); + running = false; + }); + }; + obj.addEventListener(type, func); + }; + throttle("resize", "optimizedResize"); + })(); +window.addEventListener("optimizedResize", function () { + document.body.style.zoom = "normal"; + let scale = document.body.clientWidth / 1920; + document.body.style.zoom = scale; +}); \ No newline at end of file diff --git a/gui/templates/index.html b/gui/templates/index.html new file mode 100644 index 0000000..5ee6e4b --- /dev/null +++ b/gui/templates/index.html @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + 自动商品介绍控制器 + + + + +
+
+
+

数字人控制器

+
+
+
+
+

人设:

+
+
+
+
    +
  • +

    姓名:

    + +
  • +
  • +

    性别:

    + +
  • +
  • +

    年龄:

    + +
  • + +
  • +

    出生地:

    + +
  • +
  • +

    生肖:

    + +
  • +
  • +

    星座:

    + +
  • +
  • +

    职业:

    + +
  • +
  • +

    喜好:

    + +
  • + +
  • +

    联系方式:

    + +
  • +
+
+
+
    +
  • +

    送礼敏感度:

    + + + +
  • +
  • +

    关注敏感度:

    + +
  • +
  • +

    进入敏感度:

    + +
  • +
  • +

    留言敏感度:

    + +
  • +
  • +

    冷场敏感度:

    + +
  • +
  • +

    单次互动时长:

    + + +
  • +
  • +

    声音选择:{{attribute_voice}}

    + + + + +
  • +
    +
  • +

    使用面板播放:

    + + +
  • +
+
+
+
+

Q&A文件:

+ + + + + +
+
+
+
+

接收来源:

+
+
    +
  • + + +

    抖 音

    + +
  • +
  • + + +

    麦克风

    + + + + +
  • +
  • + +

    消 息

    + +
  • +
  • + 关闭(运行中) + 正在开启... + 正在关闭... + 开启 + 保存配置 +
  • +
  • +

    注:启动后请选中场景客户端,让其前端运行,否则可能会卡顿,或者无声音。

    +
  • +
+
+
+
+
+
+ + +
    +
  • +

    名称:

    + +
  • +
  • +

    商品简介:

    + + +
  • +
  • +

    使用场景:

    + + +
  • +
  • +

    售价说明:

    + + +
  • +
  • +

    促销:

    + + +
  • +
  • +

    主播担保:

    + + +
  • +
  • +

    商品特点:

    + + +
  • +
  • +

    展示视频:

    + +
  • +
  • +

    Q&A文件:

    + +
  • +
  • +

    是否启用:

    + + +
  • +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/window.py b/gui/window.py new file mode 100644 index 0000000..fb39111 --- /dev/null +++ b/gui/window.py @@ -0,0 +1,81 @@ +import os + +import time + +from PyQt5.QtWidgets import * +from PyQt5.QtWidgets import QDialog, QHBoxLayout, QVBoxLayout +from PyQt5.QtWidgets import QGroupBox +from PyQt5.QtWebEngineWidgets import * +from PyQt5.QtCore import * +from PyQt5 import QtWidgets + +from scheduler.thread_manager import MyThread + + +class MainWindow(QMainWindow): + SigSendMessageToJS = pyqtSignal(str) + + def __init__(self): + super(MainWindow, self).__init__() + # self.setWindowFlags(Qt.WindowType.WindowShadeButtonHint) + self.setWindowTitle('Fay') + # self.setFixedSize(16 * 80, 9 * 80) + self.setGeometry(0, 0, 16 * 70, 9 * 70) + self.showMaximized() + # self.center() + self.browser = QWebEngineView() + self.browser.load(QUrl('http://127.0.0.1:5000')) + self.setCentralWidget(self.browser) + MyThread(target=self.runnable).start() + + def runnable(self): + while True: + if not self.isVisible(): + # try: + # wsa_server.get_instance().stop_server() + # wsa_server.get_web_instance().stop_server() + # thread_manager.stopAll() + # except BaseException as e: + # print(e) + os.system("taskkill /F /PID {}".format(os.getpid())) + time.sleep(0.05) + + def center(self): + screen = QtWidgets.QDesktopWidget().screenGeometry() + size = self.geometry() + self.move((screen.width() - size.width()) / 2, (screen.height() - size.height()) / 2) + + def keyPressEvent(self, event): + pass + # if event.key() == Qt.Key_F12: + # self.s = TDevWindow() + # self.s.show() + # self.browser.page().setDevToolsPage(self.s.mpJSWebView.page()) + + def OnReceiveMessageFromJS(self, strParameter): + if not strParameter: + return + + +class TDevWindow(QDialog): + def __init__(self): + super(TDevWindow, self).__init__() + self.init_ui() + + def init_ui(self): + self.mpJSWebView = QWebEngineView(self) + self.url = 'https://www.baidu.com/' + self.mpJSWebView.page().load(QUrl(self.url)) + self.mpJSWebView.show() + + self.pJSTotalVLayout = QVBoxLayout() + self.pJSTotalVLayout.setSpacing(0) + self.pJSTotalVLayout.addWidget(self.mpJSWebView) + self.pWebGroup = QGroupBox('Web View', self) + self.pWebGroup.setLayout(self.pJSTotalVLayout) + + self.mainLayout = QHBoxLayout() + self.mainLayout.setSpacing(5) + self.mainLayout.addWidget(self.pWebGroup) + self.setLayout(self.mainLayout) + self.setMinimumSize(800, 800) diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..c66c4d3 Binary files /dev/null and b/icon.png differ diff --git a/images/UE.png b/images/UE.png new file mode 100644 index 0000000..d4657e6 Binary files /dev/null and b/images/UE.png differ diff --git a/images/controller.png b/images/controller.png new file mode 100644 index 0000000..bd81c55 Binary files /dev/null and b/images/controller.png differ diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..c66c4d3 Binary files /dev/null and b/images/icon.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..7778d59 --- /dev/null +++ b/main.py @@ -0,0 +1,39 @@ +import os +import sys + +from PyQt5 import QtGui +from PyQt5.QtWidgets import QApplication + +from ai_module import ali_nls +from core import wsa_server +from gui import flask_server +from gui.window import MainWindow +from utils import config_util + + +def __clear_samples(): + if not os.path.exists("./samples"): + os.mkdir("./samples") + for file_name in os.listdir('./samples'): + if file_name.startswith('sample-') and file_name.endswith('.mp3'): + os.remove('./samples/' + file_name) + + +if __name__ == '__main__': + __clear_samples() + config_util.load_config() + # fay_booter.start() + ws_server = wsa_server.new_instance(port=10002) + ws_server.start_server() + web_ws_server = wsa_server.new_web_instance(port=10003) + web_ws_server.start_server() + + ali_nls.start() + + flask_server.start() + # MyThread(target=runnable).start() + app = QApplication(sys.argv) + app.setWindowIcon(QtGui.QIcon('icon.png')) + win = MainWindow() + win.show() + app.exit(app.exec_()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af3cb72 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +requests~=2.26.0 +selenium~=4.1.3 +numpy~=1.19.5 +pyaudio~=0.2.11 +websockets~=10.2 +ws4py~=0.5.1 +pyqt5~=5.15.6 +flask~=2.1.1 +openpyxl~=3.0.9 +pygame~=2.1.2 +flask_cors~=3.0.10 +PyQtWebEngine~=5.15.5 +eyed3~=0.9.6 +websocket~=0.2.1 +websocket-client~=1.3.2 +azure-cognitiveservices-speech~=1.21.0 +aliyun-python-sdk-core==2.13.3 \ No newline at end of file diff --git a/scheduler/thread_manager.py b/scheduler/thread_manager.py new file mode 100644 index 0000000..96107b7 --- /dev/null +++ b/scheduler/thread_manager.py @@ -0,0 +1,43 @@ +import ctypes +import threading +from threading import Thread + + +class MyThread(Thread): + def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None): + Thread.__init__(self, group=group, target=target, name=name, args=args, kwargs=kwargs, daemon=daemon) + add_thread(self) + + def get_id(self): + # returns id of the respective thread + if hasattr(self, '_thread_id'): + return self._thread_id + for id, thread in threading._active.items(): + if thread is self: + return id + + def raise_exception(self): + thread_id = self.get_id() + res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, ctypes.py_object(SystemExit)) + if res > 1: + ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0) + print('Exception raise failure') + + +__thread_list = [] + + +def add_thread(thread: MyThread): + if thread not in __thread_list: + __thread_list.append(thread) + + +def remove_thread(thread: MyThread): + if thread in __thread_list: + __thread_list.remove(thread) + + +def stopAll(): + for thread in __thread_list: + thread.raise_exception() + thread.join() diff --git a/system.conf b/system.conf new file mode 100644 index 0000000..2b9927b --- /dev/null +++ b/system.conf @@ -0,0 +1,20 @@ +[system] +# ChromeDriver 路径 +chrome_driver=./bin/chromedriver.exe + +[key] +# 阿里云 实时语音识别 服务密钥 +ali_nls_key_id= +ali_nls_key_secret= +ali_nls_app_key= + +# 微软 文字转语音 服务密钥 +ms_tts_key= + +# 讯飞 自然语言处理 服务密钥 +xf_aiui_app_id= +xf_aiui_api_key= + +# 讯飞 情绪分析 服务密钥 +xf_ltp_app_id= +xf_ltp_api_key= diff --git a/utils/config_util.py b/utils/config_util.py new file mode 100644 index 0000000..ad717f9 --- /dev/null +++ b/utils/config_util.py @@ -0,0 +1,53 @@ +import os +import json +import codecs +from configparser import ConfigParser + +config: json = None +system_config: ConfigParser = None +system_chrome_driver = None +key_ali_nls_key_id = None +key_ali_nls_key_secret = None +key_ali_nls_app_key = None +key_ms_tts_key = None +key_xf_aiui_app_id = None +key_xf_aiui_api_key = None +key_xf_ltp_app_id = None +key_xf_ltp_api_key = None + +def load_config(): + global config + global system_config + global system_chrome_driver + global key_ali_nls_key_id + global key_ali_nls_key_secret + global key_ali_nls_app_key + global key_ms_tts_key + global key_xf_aiui_app_id + global key_xf_aiui_api_key + global key_xf_ltp_app_id + global key_xf_ltp_api_key + + system_config = ConfigParser() + system_config.read('system.conf', encoding='UTF-8') + system_chrome_driver = os.path.abspath(system_config.get('system', 'chrome_driver')) + key_ali_nls_key_id = system_config.get('key', 'ali_nls_key_id') + key_ali_nls_key_secret = system_config.get('key', 'ali_nls_key_secret') + key_ali_nls_app_key = system_config.get('key', 'ali_nls_app_key') + key_ms_tts_key = system_config.get('key', 'ms_tts_key') + key_xf_aiui_app_id = system_config.get('key', 'xf_aiui_app_id') + key_xf_aiui_api_key = system_config.get('key', 'xf_aiui_api_key') + key_xf_ltp_app_id = system_config.get('key', 'xf_ltp_app_id') + key_xf_ltp_api_key = system_config.get('key', 'xf_ltp_api_key') + + config = json.load(codecs.open('config.json', encoding='utf-8')) + + +def save_config(config_data): + global config + config = config_data + file = codecs.open('config.json', mode='w', encoding='utf-8') + file.write(json.dumps(config, sort_keys=True, indent=4, separators=(',', ': '))) + file.close() + # for line in json.dumps(config, sort_keys=True, indent=4, separators=(',', ': ')).split("\n"): + # print(line) diff --git a/utils/storer.py b/utils/storer.py new file mode 100644 index 0000000..d0fdd8b --- /dev/null +++ b/utils/storer.py @@ -0,0 +1,29 @@ +import codecs +import os +from threading import Thread +import time + +FILE_URL = "datas/data-" + time.strftime("%Y%m%d%H%M%S") + ".csv" + + +def __write_to_file(text): + if not os.path.exists("datas"): + os.mkdir("datas") + file = codecs.open(FILE_URL, 'a', 'utf-8') + file.write(text + "\n") + file.close() + + +def storage_live_interact(interact): + interact_type = interact[0] + user = interact[1].replace(',', ',') + msg = interact[2].replace(',', ',') + msg_type = { + 0: '主播', + 1: '发言', + 2: '进入', + 3: '送礼', + 4: '关注' + } + timestamp = int(time.time() * 1000) + Thread(target=__write_to_file, args=["%s,%s,%s,%s\n" % (timestamp, msg_type[interact_type], user, msg)]).start() diff --git a/utils/util.py b/utils/util.py new file mode 100644 index 0000000..8dda485 --- /dev/null +++ b/utils/util.py @@ -0,0 +1,39 @@ +import codecs +import os +import random +import time + +from core import wsa_server +from scheduler.thread_manager import MyThread + +LOGS_FILE_URL = "logs/log-" + time.strftime("%Y%m%d%H%M%S") + ".log" + + +def random_hex(length): + result = hex(random.randint(0, 16 ** length)).replace('0x', '').lower() + if len(result) < length: + result = '0' * (length - len(result)) + result + return result + + +def __write_to_file(text): + if not os.path.exists("logs"): + os.mkdir("logs") + file = codecs.open(LOGS_FILE_URL, 'a', 'utf-8') + file.write(text + "\n") + file.close() + + +def printInfo(level, sender, text, send_time=-1): + if send_time < 0: + send_time = time.time() + format_time = time.strftime('%H:%M:%S', time.localtime(send_time)) + logStr = '[{}][{}] {}'.format(format_time, sender, text) + print(logStr) + if level >= 3: + wsa_server.get_web_instance().add_cmd({"panelMsg": text}) + MyThread(target=__write_to_file, args=[logStr]).start() + + +def log(level, text): + printInfo(level, "系统", text)