Update to local copy that supports animations
Also some lint fixes, but not all because this is a big enough change.
30
README.md
|
@ -13,7 +13,7 @@ A couple of other fun features exist such as minor movement animations when the
|
||||||
## Requirements and Installation
|
## Requirements and Installation
|
||||||
pyngtube is built for Python3. Python2 is long since EOLed; no support will be provided. Python 3.6 should be the minimum required version but is testing on 3.9 and 3.10.
|
pyngtube is built for Python3. Python2 is long since EOLed; no support will be provided. Python 3.6 should be the minimum required version but is testing on 3.9 and 3.10.
|
||||||
|
|
||||||
pyngtube requires tkinter, Pygame, PyYAML, and PyAudio. Fairly hefty requirements for what it does. You can get a simplified rundown in `requirements.txt`.
|
pyngtube requires WxPython, Pygame, PyYAML, and PyAudio. Fairly hefty requirements for what it does. You can get a simplified rundown in `requirements.txt`.
|
||||||
|
|
||||||
Installation should be fairly simple. The most direct approach is to install the pre-requisites using Pip like so.
|
Installation should be fairly simple. The most direct approach is to install the pre-requisites using Pip like so.
|
||||||
|
|
||||||
|
@ -45,26 +45,33 @@ If you run into build errors at the `pip install` step you're likely on an OS th
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Basic launching can be accomplished with `python main.pyw` in the pyngtube dir, or double-clicking the main.pyw file. This should launch a default experience with a very crappy hand-drawn default avatar.
|
Basic launching can be accomplished with `python main.pyw` in the pyngtube dir, or double-clicing the main.pyw file. This should launch a default experience with a very crappy hand-drawn default avatar.
|
||||||
|
|
||||||
There is no user interface. You can scroll the mousewheel inside the window to change the microphone threshold and can right-click in the window to open a dialog to select a new profile to load. See Configuration below for avatar configuration specs.
|
There is no user interface. You can scroll the mousewheel inside the window to change the microphone threshold and can right-click in the window to open a dialog to select a new profile to load. See Configuration below for avatar configuration specs.
|
||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
The main configuration file is ptv.yaml, in the same directory as main.pyw. You may also manually provide a different config file location as an argument to main.pyw.
|
The main configuration file is pyngtube.yaml. Pyngtube will search for this file in the following locations in order, taking the first it finds:
|
||||||
|
|
||||||
|
* The location provided as the first argument to main.pyw
|
||||||
|
* %HOME%/.pyngtube.yaml
|
||||||
|
* %HOME%/pyngtube.yaml
|
||||||
|
* %PWD%/pyngtube.yaml
|
||||||
|
* (pygntube_dir)/pyngtube.yaml
|
||||||
|
* Fall back to hardcoded defaults
|
||||||
|
|
||||||
This file is a standard yaml file of configuration directives. It is recommended that this main config file only include the bare necessities of configuring the program: microphone settings and window size if you need it. Then the avatar settings should be imported via a profile declared with the `profile` directive.
|
This file is a standard yaml file of configuration directives. It is recommended that this main config file only include the bare necessities of configuring the program: microphone settings and window size if you need it. Then the avatar settings should be imported via a profile declared with the `profile` directive.
|
||||||
|
|
||||||
A profile is a directory that consists of a second configuration yaml file, and all the images needed to make the avatar work. The `profile` directive should be simply the name of the directory and the yaml file must be named profile.yaml. Pyngtube will attempt to find this profile in the pyngtube installation directory. profile.yaml must declare the images to be used for the avatar.
|
A profile is a directory that consists of a second configuration yaml file, and all the images needed to make the avatar work. The `profile` directive should be simply the name of the directory and the yaml file must be named profile.yaml. Pyngtube will attempt to find this profile in the pyngtube installation directory. profile.yaml must declare the images to be used for the avatar.
|
||||||
|
|
||||||
|
Profiles can either be animated or not. An animated profile can have a list of images for each state, and will shuffle through them in a loop. Check out how the `default` and `default-animated` profiles differ for more info. The `default-animated` profile uses identical animation frames as merely an example but if you alter the images, you'll see animation.
|
||||||
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
If the app crashes, closes suddenly, or doesn't open at all, the first step is to launch the app from a command line and see if any errors are printed. The only real cause for a launch failure should be a broken config file, which will print an error explaining the problem.
|
If the app crashes, closes suddenly, or doesn't open at all, the first step is to launch the app from a command line and see if any errors are printed. The only real cause for a launch failure should be a broken config file, which will print an error explaining the problem.
|
||||||
|
|
||||||
Configuration errors are fatal (causing a forced exit) by design, including invalid keys in the configuration file.
|
Configuration errors are fatal (causing a forced exit) by design, including invalid keys in the configuration file.
|
||||||
|
|
||||||
If the terminal outputs errors about your system not being built to support tcl/tk, tkinter, etc, you're likely missing a tcl/tk package. This should only happen under Linux; Windows installs of Python should always support tk.
|
|
||||||
|
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
First and foremost, this is a personal tool I'm making available as an (arguably bad) example and starting point for someone else's personal tool. This means assumptions are made about my environment that may not work in your environment. Certain bugs I may not care about that you do. Here's a list of what I can think of that's a little "off" right now:
|
First and foremost, this is a personal tool I'm making available as an (arguably bad) example and starting point for someone else's personal tool. This means assumptions are made about my environment that may not work in your environment. Certain bugs I may not care about that you do. Here's a list of what I can think of that's a little "off" right now:
|
||||||
|
@ -74,16 +81,3 @@ First and foremost, this is a personal tool I'm making available as an (arguably
|
||||||
* For that matter, there's no way to change threshold if you're using a pointing device without a wheel short of editing the config file
|
* For that matter, there's no way to change threshold if you're using a pointing device without a wheel short of editing the config file
|
||||||
- What little debugging and error raising there is prints to the console. This needs to be reworked to raise actual GUI errors
|
- What little debugging and error raising there is prints to the console. This needs to be reworked to raise actual GUI errors
|
||||||
- pyngtube makes the awkward assumption that you have one audio input device. It should work fine even if you don't, but some configuration around this should be provided
|
- pyngtube makes the awkward assumption that you have one audio input device. It should work fine even if you don't, but some configuration around this should be provided
|
||||||
- I don't like the dependency on tkinter because it's undisclosed and cannot be put into `requirements.txt` but the greater evil prior was a depdency on wxPython which has a massive build and install process. Most py installations come with tkinter bundled
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
My Gitea instance is closed to new registrations so contributions aren't really accepted at the moment. If you must submit a code change, let me know and I'll arrange something.
|
|
||||||
|
|
||||||
Code is formatted with [python-black](https://github.com/psf/black) before commit.
|
|
||||||
|
|
||||||
|
|
||||||
## Why The Weird License?
|
|
||||||
The [ACSL](https://anticapitalist.software/) most closely aligns with how I want things I create to be used. In a better world I'd license things MIT or even some variant of CC-BY, but I've seen and experienced time and time again that fully open software tends to filter up, benefitting the largest corporations more than the common user. This results in megacorps benefitting while providing nothing in return and, in the worst cases, even demanding FOSS devs perform free labor for them to perform their security audits, investigate their change requests for their specific use cases, and the like.
|
|
||||||
|
|
||||||
I hold no illusion that anything I make will ever be important, but I refuse to even put myself in the position where that could occur. Is the ACSL enforceable? That's a matter of test-by-trial. It says what I want to say with my work; that's what matters.
|
|
||||||
|
|
97
config.py
|
@ -6,10 +6,7 @@ import yaml
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Load initial config file
|
# Load initial config file
|
||||||
if len(sys.argv) > 1:
|
config_file = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), "ptv.yaml")
|
||||||
config_file = sys.argv[1]
|
|
||||||
else:
|
|
||||||
config_file = os.path.join(os.path.dirname(__file__), "ptv.yaml")
|
|
||||||
self.load_config(config_file)
|
self.load_config(config_file)
|
||||||
|
|
||||||
# Load the profile explicitly at the end of initiailization so that
|
# Load the profile explicitly at the end of initiailization so that
|
||||||
|
@ -17,7 +14,7 @@ class Config:
|
||||||
self.load_config(os.path.join(self.profile, "profile.yaml"))
|
self.load_config(os.path.join(self.profile, "profile.yaml"))
|
||||||
|
|
||||||
def load_config(self, filename):
|
def load_config(self, filename):
|
||||||
with open(filename, "r") as infile:
|
with open(filename, encoding="utf-8") as infile:
|
||||||
self._image_closed = None
|
self._image_closed = None
|
||||||
self._image_open = None
|
self._image_open = None
|
||||||
self._image_blink_closed = None
|
self._image_blink_closed = None
|
||||||
|
@ -75,19 +72,24 @@ class Config:
|
||||||
@image_closed.setter
|
@image_closed.setter
|
||||||
def image_closed(self, filename):
|
def image_closed(self, filename):
|
||||||
# Check filename is a string
|
# Check filename is a string
|
||||||
if not isinstance(filename, str):
|
if not isinstance(filename, str) and not isinstance(filename, list):
|
||||||
print("Config parameter image_closed must be a valid filename")
|
print("Config parameter image_closed must be a valid filename or list")
|
||||||
|
|
||||||
|
if not isinstance(filename, list):
|
||||||
|
filename = [filename]
|
||||||
|
|
||||||
|
self._image_closed = []
|
||||||
|
for i in filename:
|
||||||
# Look for the file in profile dir, script dir, and working dir
|
# Look for the file in profile dir, script dir, and working dir
|
||||||
targets = [os.path.join(self.profile, filename), os.path.join(os.path.dirname(__file__), filename), filename]
|
targets = [os.path.join(self.profile, i), os.path.join(os.path.dirname(__file__), i), i]
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
self._image_closed = target
|
self._image_closed.append(target)
|
||||||
return
|
break
|
||||||
|
else:
|
||||||
# Can't find the image, fail
|
# Can't find the image, fail
|
||||||
print(f"Cannot find file {filename}")
|
print(f"Cannot find file {i}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -100,19 +102,24 @@ class Config:
|
||||||
@image_open.setter
|
@image_open.setter
|
||||||
def image_open(self, filename):
|
def image_open(self, filename):
|
||||||
# Check filename is a string
|
# Check filename is a string
|
||||||
if not isinstance(filename, str):
|
if not isinstance(filename, str) and not isinstance(filename, list):
|
||||||
print("Config parameter image_open must be a valid filename")
|
print("Config parameter image_open must be a valid filename or list")
|
||||||
|
|
||||||
|
if not isinstance(filename, list):
|
||||||
|
filename = [filename]
|
||||||
|
|
||||||
|
self._image_open = []
|
||||||
|
for i in filename:
|
||||||
# Look for the file in profile dir, script dir, and working dir
|
# Look for the file in profile dir, script dir, and working dir
|
||||||
targets = [os.path.join(self.profile, filename), os.path.join(os.path.dirname(__file__), filename), filename]
|
targets = [os.path.join(self.profile, i), os.path.join(os.path.dirname(__file__), i), i]
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
self._image_open = target
|
self._image_open.append(target)
|
||||||
return
|
break
|
||||||
|
else:
|
||||||
# Can't find the image, fail
|
# Can't find the image, fail
|
||||||
print(f"Cannot find file {filename}")
|
print(f"Cannot find file {i}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -125,19 +132,24 @@ class Config:
|
||||||
@image_blink_open.setter
|
@image_blink_open.setter
|
||||||
def image_blink_open(self, filename):
|
def image_blink_open(self, filename):
|
||||||
# Check filename is a string
|
# Check filename is a string
|
||||||
if not isinstance(filename, str):
|
if not isinstance(filename, str) and not isinstance(filename, list):
|
||||||
print("Config parameter image_blink_open must be a valid filename")
|
print("Config parameter image_blink_open must be a valid filename or list")
|
||||||
|
|
||||||
|
if not isinstance(filename, list):
|
||||||
|
filename = [filename]
|
||||||
|
|
||||||
|
self._image_blink_open = []
|
||||||
|
for i in filename:
|
||||||
# Look for the file in profile dir, script dir, and working dir
|
# Look for the file in profile dir, script dir, and working dir
|
||||||
targets = [os.path.join(self.profile, filename), os.path.join(os.path.dirname(__file__), filename), filename]
|
targets = [os.path.join(self.profile, i), os.path.join(os.path.dirname(__file__), i), i]
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
self._image_blink_open = target
|
self._image_blink_open.append(target)
|
||||||
return
|
break
|
||||||
|
else:
|
||||||
# Can't find the image, fail
|
# Can't find the image, fail
|
||||||
print(f"Cannot find file {filename}")
|
print(f"Cannot find file {i}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -150,19 +162,24 @@ class Config:
|
||||||
@image_blink_closed.setter
|
@image_blink_closed.setter
|
||||||
def image_blink_closed(self, filename):
|
def image_blink_closed(self, filename):
|
||||||
# Check filename is a string
|
# Check filename is a string
|
||||||
if not isinstance(filename, str):
|
if not isinstance(filename, str) and not isinstance(filename, list):
|
||||||
print("Config parameter image_blink_closed must be a valid filename")
|
print("Config parameter image_blink_closed must be a valid filename or list")
|
||||||
|
|
||||||
|
if not isinstance(filename, list):
|
||||||
|
filename = [filename]
|
||||||
|
|
||||||
|
self._image_blink_closed = []
|
||||||
|
for i in filename:
|
||||||
# Look for the file in profile dir, script dir, and working dir
|
# Look for the file in profile dir, script dir, and working dir
|
||||||
targets = [os.path.join(self.profile, filename), os.path.join(os.path.dirname(__file__), filename), filename]
|
targets = [os.path.join(self.profile, i), os.path.join(os.path.dirname(__file__), i), i]
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
self._image_blink_closed = target
|
self._image_blink_closed.append(target)
|
||||||
return
|
break
|
||||||
|
else:
|
||||||
# Can't find the image, fail
|
# Can't find the image, fail
|
||||||
print(f"Cannot find file {filename}")
|
print(f"Cannot find file {i}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -304,3 +321,17 @@ class Config:
|
||||||
print("Config parameter shake_intensity must be an integer")
|
print("Config parameter shake_intensity must be an integer")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self._shake_intensity = intensity
|
self._shake_intensity = intensity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def animation_delay(self):
|
||||||
|
try:
|
||||||
|
return self._animation_delay
|
||||||
|
except AttributeError:
|
||||||
|
return 10
|
||||||
|
|
||||||
|
@animation_delay.setter
|
||||||
|
def animation_delay(self, delay):
|
||||||
|
if not isinstance(delay, int):
|
||||||
|
print("Config paramter animation_delay must be an integer")
|
||||||
|
sys.exit(1)
|
||||||
|
self._animation_delay = delay
|
||||||
|
|
BIN
default-animated/default_blink_closed-1.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
default-animated/default_blink_closed-2.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
default-animated/default_blink_open-1.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
default-animated/default_blink_open-2.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
default-animated/default_closed-1.png
Normal file
After Width: | Height: | Size: 8 KiB |
BIN
default-animated/default_closed-2.png
Normal file
After Width: | Height: | Size: 8 KiB |
BIN
default-animated/default_open-1.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
default-animated/default_open-2.png
Normal file
After Width: | Height: | Size: 14 KiB |
18
default-animated/profile.yaml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
# Image set. Four images for eyes and mouth close/open
|
||||||
|
image_closed: [default_closed-1.png, default_closed-2.png]
|
||||||
|
image_open: [default_open-1.png, default_open-2.png]
|
||||||
|
image_blink_closed: [default_blink_closed-1.png, default_blink_closed-2.png]
|
||||||
|
image_blink_open: [default_blink_open-1.png, default_blink_open-2.png]
|
||||||
|
|
||||||
|
# Color to render the background of the window. Useful for color key capturing
|
||||||
|
bg_color: [0, 255, 0]
|
||||||
|
|
||||||
|
# Overriding shake settings from the main yaml file
|
||||||
|
# You can override most settings
|
||||||
|
shake_delay: 2
|
||||||
|
shake_intensity: 5
|
||||||
|
|
||||||
|
# Override blink chance from main yaml file
|
||||||
|
# This is a very twitchy default :)
|
||||||
|
blink_chance: 0.1
|
|
@ -14,4 +14,5 @@ shake_delay: 2
|
||||||
shake_intensity: 5
|
shake_intensity: 5
|
||||||
|
|
||||||
# Override blink chance from main yaml file
|
# Override blink chance from main yaml file
|
||||||
blink_chance: 0.05
|
# This is a very twitchy default :)
|
||||||
|
blink_chance: 0.1
|
||||||
|
|
81
main.pyw
|
@ -1,12 +1,13 @@
|
||||||
import array
|
import array
|
||||||
import config
|
|
||||||
import os
|
import os
|
||||||
import pyaudio
|
|
||||||
import random
|
import random
|
||||||
import typing
|
|
||||||
import tkinter
|
import tkinter
|
||||||
|
import typing
|
||||||
from tkinter import filedialog
|
from tkinter import filedialog
|
||||||
|
|
||||||
|
import pyaudio
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
# Hide Pygame "Hello world" stuff. Why does this require an envvar :/
|
# Hide Pygame "Hello world" stuff. Why does this require an envvar :/
|
||||||
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
|
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
|
||||||
|
@ -16,12 +17,13 @@ from pygame.locals import *
|
||||||
|
|
||||||
__author__ = "Trysdyn Black"
|
__author__ = "Trysdyn Black"
|
||||||
__license__ = "ACSL v1.4"
|
__license__ = "ACSL v1.4"
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
__copyright__ = "Copyright 2022, Trysdyn Black"
|
__copyright__ = "Copyright 2025, Trysdyn Black"
|
||||||
|
|
||||||
|
|
||||||
class Audio:
|
class Audio:
|
||||||
"""Audio subsystem that monitors a microphone and determines avatar state.
|
"""
|
||||||
|
Audio subsystem that monitors a microphone and determines avatar state.
|
||||||
|
|
||||||
Methods
|
Methods
|
||||||
-------
|
-------
|
||||||
|
@ -32,6 +34,8 @@ class Audio:
|
||||||
|
|
||||||
def __init__(self, channels: int, rate: int, frames: int, threshold: int = 5000, smoothing: int = 0) -> None:
|
def __init__(self, channels: int, rate: int, frames: int, threshold: int = 5000, smoothing: int = 0) -> None:
|
||||||
"""
|
"""
|
||||||
|
Init.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
channels : int
|
channels : int
|
||||||
|
@ -60,7 +64,8 @@ class Audio:
|
||||||
self.this_smooth = 0
|
self.this_smooth = 0
|
||||||
|
|
||||||
def get_state(self) -> bool:
|
def get_state(self) -> bool:
|
||||||
"""Check audio buffer and indicate if the avatar's mouth should be open this frame.
|
"""
|
||||||
|
Check audio buffer and indicate if the avatar's mouth should be open this frame.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
@ -84,7 +89,8 @@ class Audio:
|
||||||
|
|
||||||
|
|
||||||
class Tube:
|
class Tube:
|
||||||
"""Encapsulates avatar and windowing logic, handles drawing.
|
"""
|
||||||
|
Encapsulates avatar and windowing logic, handles drawing.
|
||||||
|
|
||||||
Methods
|
Methods
|
||||||
-------
|
-------
|
||||||
|
@ -104,10 +110,13 @@ class Tube:
|
||||||
blink_frames: int = 0,
|
blink_frames: int = 0,
|
||||||
shake_intensity: int = 0,
|
shake_intensity: int = 0,
|
||||||
shake_delay: int = 0,
|
shake_delay: int = 0,
|
||||||
|
animation_delay: int = 100,
|
||||||
win_size: typing.Optional[tuple] = None,
|
win_size: typing.Optional[tuple] = None,
|
||||||
bg_color: tuple = (0, 255, 0),
|
bg_color: tuple = (0, 255, 0),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
Init.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
image_closed : string
|
image_closed : string
|
||||||
|
@ -134,6 +143,9 @@ class Tube:
|
||||||
shake_delay : int
|
shake_delay : int
|
||||||
How many frames to prevent shaking after completing a prior shake.
|
How many frames to prevent shaking after completing a prior shake.
|
||||||
Defaults to 0.
|
Defaults to 0.
|
||||||
|
animation_delay : int
|
||||||
|
How many frames to wait between animation frames.
|
||||||
|
Defaults to 10.
|
||||||
win_size : tuple(int, int) or None
|
win_size : tuple(int, int) or None
|
||||||
Window size as a tuple of ints (width, height).
|
Window size as a tuple of ints (width, height).
|
||||||
Defaults to None, which uses the image size of image_closed instead.
|
Defaults to None, which uses the image size of image_closed instead.
|
||||||
|
@ -141,6 +153,7 @@ class Tube:
|
||||||
Background color as a tuple of ints (red, green, blue).
|
Background color as a tuple of ints (red, green, blue).
|
||||||
Defaults to (0, 255, 0) for greenscreen green.
|
Defaults to (0, 255, 0) for greenscreen green.
|
||||||
"""
|
"""
|
||||||
|
self.frame_num = 0
|
||||||
self.open_frames = 0
|
self.open_frames = 0
|
||||||
self.blinked_frames = 0
|
self.blinked_frames = 0
|
||||||
|
|
||||||
|
@ -149,22 +162,38 @@ class Tube:
|
||||||
pygame.display.set_caption("pyngtube")
|
pygame.display.set_caption("pyngtube")
|
||||||
|
|
||||||
# Closed mouth image is mandatory
|
# Closed mouth image is mandatory
|
||||||
self.image_closed = pygame.image.load(image_closed).convert_alpha()
|
if not isinstance(image_closed, list):
|
||||||
|
image_closed = [image_closed]
|
||||||
|
self.image_closed = []
|
||||||
|
for i in image_closed:
|
||||||
|
self.image_closed.append(pygame.image.load(i).convert_alpha())
|
||||||
|
|
||||||
# Open mouth image is optional
|
# Open mouth image is optional
|
||||||
if image_open:
|
if image_open:
|
||||||
self.image_open = pygame.image.load(image_open).convert_alpha()
|
if not isinstance(image_open, list):
|
||||||
|
image_open = [image_open]
|
||||||
|
self.image_open = []
|
||||||
|
for i in image_open:
|
||||||
|
self.image_open.append(pygame.image.load(i).convert_alpha())
|
||||||
else:
|
else:
|
||||||
self.image_open = None
|
self.image_open = None
|
||||||
|
|
||||||
# Blinking images are optional
|
# Blinking images are optional
|
||||||
if image_blink_closed:
|
if image_blink_closed:
|
||||||
self.image_blink_closed = pygame.image.load(image_blink_closed).convert_alpha()
|
if not isinstance(image_blink_closed, list):
|
||||||
|
image_blink_closed = [image_blink_closed]
|
||||||
|
self.image_blink_closed = []
|
||||||
|
for i in image_blink_closed:
|
||||||
|
self.image_blink_closed.append(pygame.image.load(i).convert_alpha())
|
||||||
else:
|
else:
|
||||||
self.image_blink_closed = None
|
self.image_blink_closed = None
|
||||||
|
|
||||||
if image_blink_open:
|
if image_blink_open:
|
||||||
self.image_blink_open = pygame.image.load(image_blink_open).convert_alpha()
|
if not isinstance(image_blink_open, list):
|
||||||
|
image_blink_open = [image_blink_open]
|
||||||
|
self.image_blink_open = []
|
||||||
|
for i in image_blink_open:
|
||||||
|
self.image_blink_open.append(pygame.image.load(i).convert_alpha())
|
||||||
else:
|
else:
|
||||||
self.image_blink_open = None
|
self.image_blink_open = None
|
||||||
|
|
||||||
|
@ -172,22 +201,24 @@ class Tube:
|
||||||
self.blink_chance = blink_chance
|
self.blink_chance = blink_chance
|
||||||
self.blink_frames = blink_frames
|
self.blink_frames = blink_frames
|
||||||
|
|
||||||
|
self.animation_delay = animation_delay
|
||||||
|
|
||||||
# If we only have a closed-mouth image (why?) use it for open-mouth too
|
# If we only have a closed-mouth image (why?) use it for open-mouth too
|
||||||
using_image_open = self.image_open if self.image_open else self.image_closed
|
using_image_open = self.image_open or self.image_closed
|
||||||
|
|
||||||
# State map for which image to use. (Mouth Opened?, Blinked?)
|
# State map for which image to use. (Mouth Opened?, Blinked?)
|
||||||
# If we don't have blinked images, we use the non-blinked images for those slots
|
# If we don't have blinked images, we use the non-blinked images for those slots
|
||||||
self.state_map = {
|
self.state_map = {
|
||||||
(False, False): self.image_closed,
|
(False, False): self.image_closed,
|
||||||
(True, False): using_image_open,
|
(True, False): using_image_open,
|
||||||
(False, True): self.image_blink_closed if self.image_blink_closed else self.image_closed,
|
(False, True): self.image_blink_closed or self.image_closed,
|
||||||
(True, True): self.image_blink_open if self.image_blink_open else using_image_open,
|
(True, True): self.image_blink_open or using_image_open,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If we specified a window size, use it. If not use closed image size
|
# If we specified a window size, use it. If not use closed image size
|
||||||
# Then we pad by 10px to allow for shaking
|
# Then we pad by 10px to allow for shaking
|
||||||
if not win_size:
|
if not win_size:
|
||||||
win_size = self.image_closed.get_size()
|
win_size = self.image_closed[0].get_size()
|
||||||
|
|
||||||
self.win_size = (
|
self.win_size = (
|
||||||
win_size[0] + (shake_intensity * 2),
|
win_size[0] + (shake_intensity * 2),
|
||||||
|
@ -202,11 +233,12 @@ class Tube:
|
||||||
self.shake_delay = shake_delay
|
self.shake_delay = shake_delay
|
||||||
|
|
||||||
# Create our drawing buffer and initialize true display size
|
# Create our drawing buffer and initialize true display size
|
||||||
self.buf = pygame.surface.Surface(self.image_closed.get_size())
|
self.buf = pygame.surface.Surface(self.image_closed[0].get_size())
|
||||||
self.display = pygame.display.set_mode(self.win_size, pygame.RESIZABLE)
|
self.display = pygame.display.set_mode(self.win_size, pygame.RESIZABLE)
|
||||||
|
|
||||||
def should_blink(self) -> bool:
|
def should_blink(self) -> bool:
|
||||||
"""Check if we're currently blinking and check blink_chance to indicate if we should be blinking this frame.
|
"""
|
||||||
|
Check if we're currently blinking and check blink_chance to indicate if we should be blinking this frame.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
@ -228,8 +260,8 @@ class Tube:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update(self, opened: bool, blinked: bool) -> None:
|
def update(self, opened: bool, blinked: bool) -> None:
|
||||||
"""Perform updates and drawing for one frame. This should be called once a frame and fed avatar state info on
|
"""
|
||||||
open mouth and blinking. This handles all the other placement, style, and drawing functions from there.
|
Perform updates and drawing for one frame.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
@ -238,6 +270,12 @@ class Tube:
|
||||||
blinked : bool
|
blinked : bool
|
||||||
true if the avatar's eyes should be closed this frame.
|
true if the avatar's eyes should be closed this frame.
|
||||||
"""
|
"""
|
||||||
|
self.frame_num += 1
|
||||||
|
frame = int(self.frame_num / self.animation_delay)
|
||||||
|
if frame >= len(self.state_map[(False, False)]):
|
||||||
|
self.frame_num = 0
|
||||||
|
frame = 0
|
||||||
|
|
||||||
# Blank with bg_color
|
# Blank with bg_color
|
||||||
self.display.fill(self.bg_color)
|
self.display.fill(self.bg_color)
|
||||||
self.buf.fill(self.bg_color)
|
self.buf.fill(self.bg_color)
|
||||||
|
@ -248,7 +286,7 @@ class Tube:
|
||||||
i = self.shake_intensity
|
i = self.shake_intensity
|
||||||
|
|
||||||
# Figure out which image we're drawing this frame using our state map
|
# Figure out which image we're drawing this frame using our state map
|
||||||
this_frame_image = self.state_map[(opened, blinked)]
|
this_frame_image = self.state_map[(opened, blinked)][frame]
|
||||||
|
|
||||||
# If the mic state is opened and an open mouth image exists, blit it
|
# If the mic state is opened and an open mouth image exists, blit it
|
||||||
# to the drawing buffer. Otherwise use the closed mouth image.
|
# to the drawing buffer. Otherwise use the closed mouth image.
|
||||||
|
@ -315,6 +353,7 @@ if __name__ == "__main__":
|
||||||
blink_frames=c.blink_frames,
|
blink_frames=c.blink_frames,
|
||||||
bg_color=c.bg_color,
|
bg_color=c.bg_color,
|
||||||
shake_delay=c.shake_delay,
|
shake_delay=c.shake_delay,
|
||||||
|
animation_delay=c.animation_delay,
|
||||||
shake_intensity=c.shake_intensity,
|
shake_intensity=c.shake_intensity,
|
||||||
)
|
)
|
||||||
rehash = False
|
rehash = False
|
||||||
|
|
6
ptv.yaml
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
# Which profile to use by default
|
# Which profile to use by default. This must be a directory that contains a
|
||||||
# This must be a directory that contains a profile.yaml and all
|
# profile.yaml and all the avatar images. This will also populate defaults
|
||||||
# the avatar images
|
# not provided here.
|
||||||
profile: 'default'
|
profile: 'default'
|
||||||
|
|
||||||
# How many times per second audio is checked
|
# How many times per second audio is checked
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
[tool.black]
|
|
||||||
line-length = 120
|
|
||||||
target-version = ['py310']
|
|
||||||
include = '\.pyw?$'
|
|
|
@ -1,6 +1,7 @@
|
||||||
numpy==1.23.4
|
numpy==1.26.4
|
||||||
Pillow==9.3.0
|
Pillow==9.3.0
|
||||||
PyAudio==0.2.12
|
PyAudio==0.2.12
|
||||||
pygame==2.1.2
|
pygame>=2.1.2
|
||||||
PyYAML==6.0
|
PyYAML==6.0.2
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
wxPython==4.2.2
|
||||||
|
|