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
210 changes: 80 additions & 130 deletions voxflow/overlay.py
Original file line number Diff line number Diff line change
@@ -1,95 +1,90 @@
"""VoxFlow Recording Overlay – bottom-center waveform bar.
"""VoxFlow Recording Overlay — minimalist indicator badge.

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)
Created fresh on show(), destroyed completely on hide().
No persistent window, no fade tricks — guaranteed to appear
only during active recording.
"""
import math
import threading
import tkinter as tk
import customtkinter as ctk
from typing import Optional


class RecordingOverlay:
"""Elegant bottom-centre overlay shown only during recording."""
"""Small badge shown at bottom-center of screen while recording."""

W = 520
H = 70
BOTTOM_MARGIN = 80
W = 240
H = 50
BOTTOM_MARGIN = 70

def __init__(self):
self._win: Optional[ctk.CTkToplevel] = None
self._win: Optional[tk.Toplevel] = None
self._canvas: Optional[tk.Canvas] = None
self._alive = False
self._phase = 0.0
self._level = 0.0
self._alpha = 0.0
self._hiding = False
self._running = False
self._parent = None
self._after_id = None

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

def show(self, parent=None):
"""Make overlay visible – call from main thread."""
if self._alive:
"""Show badge — creates a fresh window on every call."""
if self._win is not None:
return
self._parent = parent
self._alive = True
self._hiding = False
self._alpha = 0.0
self._running = True
if parent:
parent.after(0, self._create)

def hide(self):
"""Start fade-out – call from main thread."""
self._alive = False
self._hiding = True
"""Destroy the badge window completely."""
self._running = False
if self._parent:
self._parent.after(0, self._destroy)

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

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

def _create(self):
if self._win is not None:
return
try:
win = ctk.CTkToplevel(self._parent)
win = tk.Toplevel(self._parent)
self._win = win

win.overrideredirect(True)
win.attributes("-topmost", True)
win.attributes("-alpha", 0.0)
win.resizable(False, False)
win.attributes("-alpha", 0.95)

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

# 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, bd=0,
win,
width=self.W, height=self.H,
bg="#120d2b",
highlightthickness=0,
bd=0,
)
self._canvas.pack(fill="both", expand=True)
self._canvas.pack()

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

def _destroy(self):
self._running = False
try:
if self._after_id and self._win:
self._win.after_cancel(self._after_id)
if self._canvas:
self._canvas.delete("all")
except Exception:
pass
try:
Expand All @@ -99,116 +94,71 @@ def _destroy(self):
pass
self._win = None
self._canvas = None
self._hiding = False
self._after_id = None

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

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

# 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)

try:
self._win.attributes("-alpha", self._alpha)
except Exception:
return

if self._hiding and self._alpha <= 0.0:
if self._parent:
self._parent.after(0, self._destroy)
return

self._phase += 0.11
self._phase += 0.13
self._draw()

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

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

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

# ── 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)

# Filled dark panel
c.create_rectangle(3, 3, W - 3, H - 3, fill="#100e28", outline="")

# Top inner shine
c.create_rectangle(4, 4, W - 4, 18, fill="#ffffff09", outline="")

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

# 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 = int(9 + pulse * 2)
c.create_oval(cx - dr, cy - dr, cx + dr, cy + dr,
fill="#ef4444", outline="#fca5a5", width=1)

# 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 - 10,
text="Nagrywam...",
fill="#fca5a5", font=("Segoe UI", 11, "bold"),
# Background with thin purple border
c.create_rectangle(0, 0, W, H, fill="#120d2b", outline="#5b21b6", width=2)

cy = H // 2

# ── Pulsing red dot ───────────────────────────────────────
pulse = (math.sin(self._phase * 3.5) + 1) / 2
dot_x = 22
outer_r = int(10 + pulse * 3)
inner_r = 7

# Outer glow
c.create_oval(dot_x - outer_r, cy - outer_r,
dot_x + outer_r, cy + outer_r,
fill="#7f1d1d", outline="")
# Core
c.create_oval(dot_x - inner_r, cy - inner_r,
dot_x + inner_r, cy + inner_r,
fill="#ef4444", outline="")

# ── Label ─────────────────────────────────────────────────
c.create_text(42, cy - 8,
text="NAGRYWAM",
fill="#fca5a5",
font=("Segoe UI", 9, "bold"),
anchor="w")
c.create_text(62, cy + 8,
text="VoxFlow \u2022 zwolnij klawisz aby zakonczyc",
fill="#6b5e9b", font=("Segoe UI", 8),
c.create_text(42, cy + 8,
text="zwolnij klawisz aby zakonczyc",
fill="#6b5e9b",
font=("Segoe UI", 7),
anchor="w")

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

# ── Waveform bars ─────────────────────────────────────────
n = 32
bw, gap = 4, 2
x0 = 236
max_h = 22
# ── 7 mini waveform bars ──────────────────────────────────
n = 7
bw = 5
gap = 4
total = n * bw + (n - 1) * gap
x0 = W - total - 12

for i in range(n):
x = x0 + i * (bw + gap)
if x + bw > W - 6:
break
t = self._phase * 2.8 + i * 0.55
wave = abs(math.sin(t)) * 0.65 + abs(math.sin(t * 0.6 + 1.0)) * 0.35
amp = max(3, min(18, int((wave * 0.5 + self._level * 0.7) * 18)))

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
col = ("#f43f5e" if ratio > 0.78
else "#a78bfa" if ratio > 0.50
else "#7c3aed" if ratio > 0.28
else "#4c1d95")
x = x0 + i * (bw + gap)
ratio = amp / 18
col = "#f43f5e" if ratio > 0.75 else "#a78bfa" if ratio > 0.45 else "#7c3aed"

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