Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 113 additions & 133 deletions voxflow/overlay.py
Original file line number Diff line number Diff line change
@@ -1,234 +1,214 @@
"""VoxFlow Recording Overlay - Bottom-center animated waveform bar.
"""VoxFlow Recording Overlay – bottom-center waveform bar.

Shows only while recording (show/hide called from app.py).
Built by AI Evolution Polska
Uses CTkToplevel so it renders reliably on Windows without
canvas transparency tricks.

show() – called when recording starts
hide() – called when recording stops
set_level(0-1) – audio level (called from recorder callback)
"""
import threading
import math
import threading
import tkinter as tk
import customtkinter as ctk
from typing import Optional


class RecordingOverlay:
"""Floating overlay that shows recording waveform at bottom-center of screen."""
"""Elegant bottom-centre overlay shown only during recording."""

W = 520
H = 68
MARGIN_BOTTOM = 70
H = 70
BOTTOM_MARGIN = 80

def __init__(self):
self._root: Optional[tk.Toplevel] = None
self._win: Optional[ctk.CTkToplevel] = None
self._canvas: Optional[tk.Canvas] = None
self._alive = False
self._phase = 0.0
self._level = 0.0
self._alpha = 0.0 # current opacity (for fade in/out)
self._fading_out = False
self._alpha = 0.0
self._hiding = False
self._parent = None
self._after_id = None

# ── Public API ────────────────────────────────────────────────

def show(self, parent=None):
"""Show the overlay — called when recording starts."""
"""Make overlay visible – call from main thread."""
if self._alive:
return
self._parent = parent
self._alive = True
self._fading_out = False
self._hiding = False
self._alpha = 0.0
if parent:
parent.after(0, self._create_window)
parent.after(0, self._create)

def hide(self):
"""Hide the overlay — called when recording stops."""
"""Start fade-out – call from main thread."""
self._alive = False
self._fading_out = True
self._hiding = True

def set_level(self, level: float):
"""Update audio level (0.0–1.0) for waveform animation."""
self._level = min(1.0, max(0.0, level))
"""Update audio amplitude for waveform (0.0–1.0)."""
self._level = max(0.0, min(1.0, level))

# ── Window ────────────────────────────────────────────────────
# ── Window lifecycle ──────────────────────────────────────────

def _create_window(self):
def _create(self):
try:
self._root = tk.Toplevel(self._parent)
win = self._root
win = ctk.CTkToplevel(self._parent)
self._win = win

win.overrideredirect(True)
win.attributes("-topmost", True)
win.attributes("-alpha", 0.0) # start invisible, fade in
win.attributes("-alpha", 0.0)
win.resizable(False, False)

sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
x = (sw - self.W) // 2
y = sh - self.H - self.MARGIN_BOTTOM
y = sh - self.H - self.BOTTOM_MARGIN
win.geometry(f"{self.W}x{self.H}+{x}+{y}")

# Match canvas background to panel color — no transparent key needed
win.configure(bg="#0d0b1e")
# Dark panel background
win.configure(fg_color="#0d0b1e")

# Canvas fills entire window – draws waveform + mic dot
self._canvas = tk.Canvas(
win, width=self.W, height=self.H,
bg="#0d0b1e", highlightthickness=0,
bg="#0d0b1e", highlightthickness=0, bd=0,
)
self._canvas.pack()
self._animate()
self._canvas.pack(fill="both", expand=True)

self._tick()
except Exception as e:
print(f"Overlay error: {e}")
print(f"[Overlay] create error: {e}")

def _destroy_window(self):
def _destroy(self):
try:
if self._root:
self._root.destroy()
if self._after_id and self._win:
self._win.after_cancel(self._after_id)
except Exception:
pass
self._root = None
try:
if self._win:
self._win.destroy()
except Exception:
pass
self._win = None
self._canvas = None
self._fading_out = False
self._hiding = False
self._after_id = None

# ── Animation ─────────────────────────────────────────────────

def _tick(self):
if not self._win or not self._canvas:
return

# ── Animation loop ────────────────────────────────────────────
# Fade in / fade out
if self._hiding:
self._alpha = max(0.0, self._alpha - 0.085)
elif self._alpha < 0.94:
self._alpha = min(0.94, self._alpha + 0.07)

def _animate(self):
if not self._canvas or not self._root:
try:
self._win.attributes("-alpha", self._alpha)
except Exception:
return

# --- Fade in / fade out ---
if self._fading_out:
self._alpha = max(0.0, self._alpha - 0.08)
try:
self._root.attributes("-alpha", self._alpha)
except Exception:
pass
if self._alpha <= 0.0:
if self._parent:
self._parent.after(0, self._destroy_window)
return
elif self._alpha < 0.93:
self._alpha = min(0.93, self._alpha + 0.07)
try:
self._root.attributes("-alpha", self._alpha)
except Exception:
pass
if self._hiding and self._alpha <= 0.0:
if self._parent:
self._parent.after(0, self._destroy)
return

self._phase += 0.11
self._draw()

try:
self._root.after(35, self._animate)
self._after_id = self._win.after(35, self._tick)
except Exception:
pass

# ── Drawing ───────────────────────────────────────────────────

def _draw(self):
c = self._canvas
W, H = self.W, self.H
c.delete("all")

# ── Border glow (layered rectangles, outer→inner) ────────
glow_colors = ["#3b1f6a", "#4c2181", "#5d28a0", "#6d28d9"]
radii = [16, 14, 13, 12]
for col, r in zip(glow_colors, radii):
pad = radii[0] - r
self._rrect(c, pad, pad, W - pad, H - pad, r=r, fill="", outline=col, width=2)

# ── Glass panel fill ──────────────────────────────────────
self._rrect(c, 3, 3, W - 3, H - 3, r=12,
fill="#100e28", outline="")
# ── Background panel with purple border ───────────────────
# Outer glow (multiple rects, darker to lighter)
for i, col in enumerate(["#2a1060", "#3d1a8a", "#5b21b6", "#7c3aed"]):
pad = 3 - i if i < 3 else 0
c.create_rectangle(pad, pad, W - pad, H - pad,
fill="", outline=col, width=1)

# Inner top-shine strip
self._rrect(c, 4, 4, W - 4, 4 + H // 3, r=11,
fill="#ffffff0a", outline="")
# Filled dark panel
c.create_rectangle(3, 3, W - 3, H - 3, fill="#100e28", outline="")

# Thin bright border top-cap
self._rrect(c, 3, 3, W - 3, H - 3, r=12,
fill="", outline="#7c3aed", width=1)
# Top inner shine
c.create_rectangle(4, 4, W - 4, 18, fill="#ffffff09", outline="")

# ── Pulsing record dot (left) ─────────────────────────────
# ── Pulsing mic dot (left) ────────────────────────────────
pulse = (math.sin(self._phase * 3.0) + 1) / 2
cx, cy = 38, H // 2

# Outer glow rings
for ri, col in [(22, "#ef444418"), (16, "#ef444438"), (12, "#ef444470")]:
# Glow rings
for ri, col in ((22, "#ef444415"), (16, "#ef444435"), (11, "#ef444465")):
r = ri + pulse * 4
c.create_oval(cx - r, cy - r, cx + r, cy + r,
fill=col, outline="")

# Core dot
dr = 9 + pulse * 2
dr = int(9 + pulse * 2)
c.create_oval(cx - dr, cy - dr, cx + dr, cy + dr,
fill="#ef4444", outline="#fca5a5", width=1)

# Mic icon inside dot
c.create_oval(cx - 3, cy - 6, cx + 3, cy, fill="white", outline="")
c.create_line(cx, cy, cx, cy + 5, fill="white", width=2)
c.create_line(cx - 4, cy + 5, cx + 4, cy + 5, fill="white", width=2)
# Mic icon inside
c.create_oval(cx - 3, cy - 7, cx + 3, cy - 1, fill="white", outline="")
c.create_line(cx, cy - 1, cx, cy + 5, fill="white", width=2)
c.create_arc(cx - 6, cy - 3, cx + 6, cy + 7,
start=0, extent=-180, style="arc", outline="white", width=2)

# ── Text ──────────────────────────────────────────────────
c.create_text(62, cy - 9,
c.create_text(62, cy - 10,
text="Nagrywam...",
fill="#fca5a5", font=("Segoe UI", 11, "bold"),
anchor="w")
c.create_text(62, cy + 9,
c.create_text(62, cy + 8,
text="VoxFlow \u2022 zwolnij klawisz aby zakonczyc",
fill="#6d5fa0", font=("Segoe UI", 8),
fill="#6b5e9b", font=("Segoe UI", 8),
anchor="w")

# Thin separator line
c.create_line(230, 12, 230, H - 12, fill="#3d2d70", width=1)
# Thin vertical divider
c.create_line(228, 14, 228, H - 14, fill="#3d2d6d", width=1)

# ── Waveform bars ─────────────────────────────────────────
n_bars = 32
bw = 4
gap = 2
x0 = 238
max_h = 20

for i in range(n_bars):
if x0 + i * (bw + gap) > W - 8:
break
t = self._phase * 2.6 + i * 0.38
wave = abs(math.sin(t)) * 0.6 + abs(math.sin(t * 0.7 + 1.1)) * 0.4
amp = (wave * 0.45 + self._level * 0.7) * max_h + 3
amp = min(amp, max_h)
n = 32
bw, gap = 4, 2
x0 = 236
max_h = 22

for i in range(n):
x = x0 + i * (bw + gap)
if x + bw > W - 6:
break

t = self._phase * 2.5 + i * 0.40
wave = abs(math.sin(t)) * 0.6 + abs(math.sin(t * 0.73 + 1.1)) * 0.4
amp = max(3, min(max_h, (wave * 0.45 + self._level * 0.70) * max_h))

ratio = amp / max_h
if ratio > 0.78:
col = "#f43f5e"
elif ratio > 0.50:
col = "#a78bfa"
elif ratio > 0.28:
col = "#7c3aed"
else:
col = "#4c1d95"

# Bar body
col = ("#f43f5e" if ratio > 0.78
else "#a78bfa" if ratio > 0.50
else "#7c3aed" if ratio > 0.28
else "#4c1d95")

c.create_rectangle(x, cy - amp, x + bw, cy + amp,
fill=col, outline="")
# Rounded top cap
c.create_oval(x - 1, cy - amp - 2, x + bw + 1, cy - amp + 2,
# Round top cap
c.create_oval(x, cy - amp - 2, x + bw, cy - amp + 2,
fill=col, outline="")

# ── Helper ────────────────────────────────────────────────────

@staticmethod
def _rrect(canvas, x1, y1, x2, y2, r=12, **kw):
"""Draw a rounded rectangle on canvas."""
# Clamp radius
r = min(r, (x2 - x1) // 2, (y2 - y1) // 2)
points = [
x1 + r, y1,
x2 - r, y1,
x2, y1,
x2, y1 + r,
x2, y2 - r,
x2, y2,
x2 - r, y2,
x1 + r, y2,
x1, y2,
x1, y2 - r,
x1, y1 + r,
x1, y1,
x1 + r, y1,
]
canvas.create_polygon(points, smooth=True, **kw)