141 lines
4.2 KiB
Python
141 lines
4.2 KiB
Python
import os
|
|
import io
|
|
import random
|
|
import threading
|
|
import asyncio
|
|
import edge_tts
|
|
import sounddevice as sd
|
|
import soundfile as sf
|
|
from PIL import Image, ImageTk
|
|
import tkinter as tk
|
|
from tkinter.scrolledtext import ScrolledText
|
|
import re
|
|
|
|
def normalize_punctuation(text: str) -> str:
|
|
text = re.sub(r'\.\s+', ', ', text) #period
|
|
text = re.sub(r'!\s+', ', ', text) #exclamation point
|
|
text = re.sub(r'\?\s+', ', ', text) #question mark
|
|
|
|
if text.endswith("."):
|
|
text = text[:-1]
|
|
|
|
return text.strip()
|
|
|
|
class VoiceImagePlayer:
|
|
def __init__(self, face_path, size=(760, 760)):
|
|
self.root = tk.Tk()
|
|
self.root.title("F.A.I.T.H")
|
|
self.root.resizable(False, False)
|
|
self.root.geometry("760x900")
|
|
|
|
self.fixed_size = size
|
|
|
|
self.face_original = Image.open(face_path).resize(self.fixed_size, Image.LANCZOS)
|
|
self.face_label = tk.Label(self.root, bg="#001f4d", borderwidth=0, highlightthickness=0)
|
|
self.face_label.place(relx=0.5, rely=0.4, anchor="center")
|
|
|
|
self.scale = 1.0
|
|
self.playing = False
|
|
|
|
self.text_box = ScrolledText(
|
|
self.root,
|
|
fg="#00ffff",
|
|
bg="#001f4d",
|
|
insertbackground="#00ffff",
|
|
height=12,
|
|
borderwidth=0,
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
wrap="word",
|
|
font=("Consolas", 10),
|
|
padx=10,
|
|
pady=10
|
|
)
|
|
|
|
self.text_box.place(relx=0.5, rely=0.85, anchor="center")
|
|
|
|
self.animate()
|
|
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
def on_close(self):
|
|
os._exit(0)
|
|
|
|
def write_log(self, text):
|
|
self.text_box.configure(state="normal")
|
|
self.text_box.insert(tk.END, text + "\n")
|
|
self.text_box.see(tk.END)
|
|
self.text_box.configure(state="disabled")
|
|
|
|
# Animation
|
|
def start_animation(self):
|
|
self.playing = True
|
|
|
|
def stop_animation(self):
|
|
self.playing = False
|
|
|
|
def animate(self):
|
|
if self.playing:
|
|
self.scale += random.uniform(-0.015, 0.02)
|
|
self.scale = min(max(self.scale, 1.02), 1.10)
|
|
else:
|
|
if abs(self.scale - 1.0) < 0.003:
|
|
self.scale = 1.0
|
|
elif self.scale > 1.0:
|
|
self.scale -= 0.009
|
|
else:
|
|
self.scale += 0.005
|
|
|
|
base_w, base_h = self.fixed_size
|
|
new_size = (int(base_w * self.scale), int(base_h * self.scale))
|
|
resized = self.face_original.resize(new_size, Image.LANCZOS)
|
|
tk_img = ImageTk.PhotoImage(resized)
|
|
self.face_label.configure(image=tk_img)
|
|
self.face_label.image = tk_img
|
|
|
|
self.root.after(50, self.animate)
|
|
|
|
VOICE = "en-GB-OllieMultilingualNeural"
|
|
|
|
def edge_speak(text: str, img_player: VoiceImagePlayer, voice: str = VOICE):
|
|
if not text.strip():
|
|
return
|
|
|
|
optimized_text = normalize_punctuation(text)
|
|
|
|
def _thread_target():
|
|
async def _speak():
|
|
communicate = edge_tts.Communicate(
|
|
optimized_text,
|
|
voice,
|
|
pitch="+2Hz",
|
|
rate="+8%"
|
|
)
|
|
|
|
audio_buffer = bytearray()
|
|
first_audio_received = False
|
|
|
|
async for chunk in communicate.stream():
|
|
if chunk["type"] == "audio":
|
|
audio_buffer.extend(chunk["data"])
|
|
if not first_audio_received:
|
|
first_audio_received = True
|
|
img_player.start_animation()
|
|
|
|
wav_bytes = io.BytesIO(audio_buffer)
|
|
|
|
try:
|
|
data, samplerate = sf.read(wav_bytes, dtype="float32")
|
|
except Exception as e:
|
|
print("Decode error:", e)
|
|
return
|
|
|
|
sd.play(data, samplerate)
|
|
sd.wait()
|
|
img_player.stop_animation()
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(_speak())
|
|
loop.close()
|
|
|
|
threading.Thread(target=_thread_target, daemon=True).start() |