Voice Waveform¶
100 bars on a Canvas, redrawn 25 times a second from a background thread.
What it shows¶
Canvasandnib.draw.Rectcanvas.draw()called from a thread- A speech-like envelope: random word clusters with smooth falloff
- Start and stop from a button, fade out on stop
import math
import random
import threading
import time
import nib
def main(app: nib.App):
app.title = "Voice"
app.icon = nib.SFSymbol("waveform")
app.width = 420
app.height = 140
num_bars = 100
bar_width = 2
bar_gap = 2
max_height = 70
canvas_width = num_bars * (bar_width + bar_gap) + 20
canvas_height = 90
center_y = canvas_height / 2
canvas = nib.Canvas(width=canvas_width, height=canvas_height)
is_talking = [False]
levels = [0.05] * num_bars
envelope = [0.0] * num_bars # Speech envelope pattern
def draw():
commands = []
for i, level in enumerate(levels):
x = 10 + i * (bar_width + bar_gap)
height = max(2, level * max_height)
y = center_y - height / 2
# Brightness: dim when low, bright when high
brightness = int(100 + level * 155)
color = f"#{brightness:02x}{brightness:02x}{brightness:02x}"
commands.append(
nib.draw.Rect(
x=x,
y=y,
width=bar_width,
height=height,
corner_radius=1,
fill=color,
)
)
canvas.draw(commands)
def generate_speech_envelope():
"""Generate natural speech-like envelope with words and pauses."""
# Create 3-5 "word" clusters
num_words = random.randint(3, 5)
env = [0.1] * num_bars
for _ in range(num_words):
# Random position and width for each "word"
center = random.randint(10, num_bars - 10)
width = random.randint(8, 20)
intensity = random.uniform(0.5, 1.0)
for i in range(num_bars):
dist = abs(i - center)
if dist < width:
# Smooth falloff
falloff = 1 - (dist / width) ** 2
env[i] = max(env[i], intensity * falloff)
return env
def animate():
def run():
nonlocal envelope
frame = 0
envelope = generate_speech_envelope()
while is_talking[0]:
# Regenerate envelope periodically (new "sentence")
if frame % 40 == 0:
envelope = generate_speech_envelope()
# Animate bars based on envelope + randomness
for i in range(num_bars):
base = envelope[i]
# Add variation
variation = random.uniform(0.7, 1.3)
target = base * variation * random.uniform(0.5, 1.0)
# Smooth transition
levels[i] = levels[i] * 0.4 + target * 0.6
draw()
frame += 1
time.sleep(0.04)
# Fade out
for _ in range(15):
for i in range(num_bars):
levels[i] *= 0.75
draw()
time.sleep(0.025)
threading.Thread(target=run, daemon=True).start()
def toggle():
is_talking[0] = not is_talking[0]
if is_talking[0]:
animate()
draw()
is_talking[0] = True
animate()
app.build(
nib.VStack(
controls=[
canvas,
nib.Button("Talk", action=toggle),
],
spacing=12,
padding=10,
)
)
nib.run(main)
Walkthrough¶
One frame¶
draw() builds one nib.draw.Rect per bar. Bar height and grey level both come from levels[i]. canvas.draw(commands) replaces the previous frame.
The envelope¶
generate_speech_envelope() places 3 to 5 "words" at random positions, each 8 to 20 bars wide with a quadratic falloff. The animation loop replaces the envelope every 40 frames.
The animation thread¶
animate() starts a daemon thread. Per frame: target = envelope[i] * noise, then levels[i] = levels[i] * 0.4 + target * 0.6 to smooth the jump, draw(), time.sleep(0.04). When is_talking[0] turns false the loop exits and 15 fade frames multiply every level by 0.75.
Property writes and canvas.draw() are safe from any thread. See Threads and long work.
Starting¶
is_talking[0] = True and animate() run once before app.build(), so the popover opens animated. The Talk button toggles.
Related¶
- Canvas
- Primitives:
nib.draw.Rect - Canvas
- Reactivity: threads