Music

Audio clips played at the cursor with their length, volume and fades, and animations cut on the beats and bars of a track's tempo.

Music
View on GitHubgaanim examples/audio/music.py
Code · 122 lines
"""Music: audio clips played at the cursor, with their length, volume and fades, and cutting on the beat.

Each lane is a strip of time with the clip drawn where it sounds: its height
is the volume and its slopes are the fades, and a playhead sweeps the lanes
as they play. The first segment plays four short clips one after another; the
second plays background music under a beat grid timed by the tempo that the
music's analysis estimates.
"""

import os

from gaanim import Easing, Scene, Zones

BEAT = "assets/beat.ogg"  # six seconds of drums and bass at 120 BPM

scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")


class Lanes:
    """Strips of ``span`` seconds across the given zones, each with a playhead."""

    def __init__(self, zones, span):
        self.zones, self.span = zones, span
        self.left, self.right = zones[0].left, zones[0].right
        self.playheads = []
        for zone in zones:
            scene.geometry.line(self.left, zone.bottom, self.right, zone.bottom).stroke(rule, 0.04)
            playhead = scene.geometry.line(self.left, zone.bottom, self.left, zone.top).stroke(foreground, 0.04)
            self.playheads.append(playhead)

    def x(self, t):
        return self.left + (self.right - self.left) * t / self.span

    def clip(self, lane, start, seconds, volume=1.0, fade_in=0.0, fade_out=0.0):
        """Draw a clip's gain over time: it rises over ``fade_in`` and falls over ``fade_out``."""
        zone = self.zones[lane]
        y, top = zone.bottom, zone.bottom + zone.height * 0.85 * volume
        end = start + seconds
        points = [(self.x(start), y), (self.x(start + fade_in), top), (self.x(end - fade_out), top), (self.x(end), y)]
        scene.geometry.polygon(points).fill(accent).no_stroke()

    def sweep(self):
        """Move the playheads across the lanes, from the cursor without moving it."""
        distance = self.right - self.left
        scene.launch([
            playhead.animate.shift_by(distance, 0).duration(self.span).easing(Easing.LINEAR)
            for playhead in self.playheads
        ])


def code(text, zone, anchor="left"):
    return scene.text(text, role="code").place(zone, anchor=anchor)


# A clip starts where scene.play puts the cursor. With duration it plays that
# many seconds and the play lasts as long, so each clip starts where the
# previous one ended. volume is a linear gain; fades ramp it from and to 0.
scene.segment("clips")
CLIP = 1.5
CLIPS = [
    ("scene.media.audio(BEAT, duration=1.5)", {}),
    ("scene.media.audio(BEAT, duration=1.5, volume=0.4)", {"volume": 0.4}),
    ("scene.media.audio(BEAT, duration=1.5, fade_in=0.6)", {"fade_in": 0.6}),
    ("scene.media.audio(BEAT, duration=1.5, fade_out=0.6)", {"fade_out": 0.6}),
]
page = scene.layout.zones(Zones.rows(["1fr", "2fr"] * len(CLIPS), gap="12px", padding="page_padding"))
lanes = Lanes([page[2 * index + 1] for index in range(len(CLIPS))], CLIP * len(CLIPS))
for index, (text, options) in enumerate(CLIPS):
    code(text, page[2 * index])
    lanes.clip(index, CLIP * index, CLIP, **options)
lanes.sweep()
for text, options in CLIPS:
    scene.play(scene.media.audio(BEAT, duration=CLIP, **options))
scene.wait(0.4)

# With end the file is cut at that second, but the play does not wait for it:
# the cursor stays, and what follows is scheduled over the music. Here, a beat
# grid: tempo() estimates the music's beat, scene.tempo lines the scene up
# with it, and wait_until moves the cursor to a bar or to a beat.
scene.segment("background")
page = scene.layout.zones(
    Zones.rows(["1fr", "2fr", "1fr", "1fr", "1fr", "1fr", "1fr", "2fr", "1fr"], gap="12px", padding="page_padding")
)
code("music = scene.media.audio(BEAT, end=6.0, fade_in=1.0, fade_out=2.0)", page[0])
lanes = Lanes([page[1]], 6.0)
lanes.clip(0, 0.0, 6.0, fade_in=1.0, fade_out=2.0)
code("scene.play(music)", page[2])
music = scene.media.audio(BEAT, end=6.0, fade_in=1.0, fade_out=2.0)
beat = music.tempo()
code("beat = music.tempo()", page[3])
reading = f"{beat.bpm:.0f} BPM, first beat at {beat.offset:.2f} s, confidence {beat.confidence:.2f}"
scene.text(reading).fill(muted).place(page[3], anchor="right")
code("scene.tempo(beat.bpm, offset=music.start + beat.offset)", page[4])
code("scene.wait_until(beat=4 * bar + step)", page[5])
code("scene.play(dot.animate.grow_from_center().duration(scene.beats(0.5)))", page[6])
bars = page[7].split(Zones.columns(3, gap="96px"))
dots = []
for bar, zone in enumerate(bars):
    cells = zone.split(Zones.columns(4, gap="12px"))
    dots.append([scene.geometry.circle(min(cell.width, cell.height) * 0.42).move_to(*cell.center) for cell in cells])
    code(f"wait_until(bar={bar})", page[8].split(Zones.columns(3, gap="96px"))[bar], "left")

lanes.sweep()
scene.play(music)
scene.tempo(beat.bpm, offset=music.start + beat.offset)
for bar, row in enumerate(dots):
    scene.wait_until(bar=bar)
    for step, dot in enumerate(row):
        scene.wait_until(beat=4 * bar + step)
        scene.play(dot.animate.grow_from_center().duration(scene.beats(0.5)))
scene.wait(0.6)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # The playhead in each clip, the clips done, then the beat grid filling.
    scene.snapshots(snapshots, [0.7, 2.2, 3.7, 5.2, 6.2, 7.2, 9.2, 11.2, 12.9])
else:
    scene.render()

Sound effects

Sounds anchored to an animation with volume, delay and offset, sounds placed at a second, and sounds played by transitions and staggers.

Sound effects
View on GitHubgaanim examples/audio/sound_effects.py
Code · 158 lines
"""Sound effects: sounds anchored to an animation, placed at a second, or played by a transition.

Each row is a strip of time. The accent bar is an animation as it plays, the
tick is the cursor where the row's code runs, and a dot with ripples marks the
instant its sound starts (smaller ripples for a quieter sound). Rows play one
after another so each sound can be heard on its own. The last segment puts the
three kinds of anchoring to use.
"""

import os

from gaanim import Easing, Scene, Transition, Zones, sequence, stagger

POP = "assets/pop.wav"
TYPING = "assets/typing.wav"
WHOOSH = "assets/whoosh.wav"

WINDOW = 1.4  # seconds each strip spans
LEAD = 0.3  # where on its strip each row's cursor sits
GROW = 0.6  # length of the animation that carries the sound

scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")


class Strip:
    """A strip of WINDOW seconds across ``zone``, with the cursor's tick at LEAD."""

    def __init__(self, zone):
        zone = zone.inset((0, "24px"))
        (self.left, self.y), (self.right, _) = zone.point("left"), zone.point("right")
        scene.geometry.line(self.left, self.y, self.right, self.y).stroke(rule, 0.04)
        scene.geometry.rect(0.04, 0.6).fill(muted).move_to(self.x(LEAD), self.y)
        self.elapsed = scene.geometry.line(self.left, self.y, self.right, self.y).stroke(muted, 0.04)

    def x(self, t):
        """The x of ``t`` seconds into the strip."""
        return self.left + (self.right - self.left) * t / WINDOW

    def bar(self, start, seconds=GROW):
        """A bar that an animation of ``seconds`` draws from ``start``."""
        return scene.geometry.line(self.x(start), self.y, self.x(start + seconds), self.y).stroke(accent, 0.24)

    def sound(self, t, volume=1.0):
        """Mark a sound at ``t``: a dot that appears there and sends two ripples sized by ``volume``."""
        x = self.x(t)
        dot = scene.geometry.dot(0.14).fill(foreground).move_to(x, self.y)
        marks = [dot.animate.fade_in().duration(0.05).delay(t)]
        for lag in (0.0, 0.2):
            ring = scene.geometry.circle(0.14).no_fill().stroke(foreground, 0.05).move_to(x, self.y).opacity(0)
            ripple = ring.animate.keyframes([0, 0.05, 1], scale=[1, 1, 1 + 4 * volume], opacity=[0, 1, 0])
            marks.append(ripple.duration(0.8).delay(t + lag))
        return marks

    def start(self, *marks):
        """Sweep the strip and launch its sound marks, then move the cursor to the tick."""
        self.began = scene.cursor
        scene.launch([self.elapsed.animate.create().duration(WINDOW).easing(Easing.LINEAR), *marks])
        scene.wait(LEAD)

    def finish(self):
        """Wait until the strip has run out."""
        scene.wait(self.began + WINDOW - scene.cursor)


def rows(count):
    """``count`` rows of a code column and a strip column, padded from the frame."""
    return scene.layout.zones(
        Zones.grid(rows=count, columns=["3fr", "2fr"], column_gap="48px", padding="page_padding")
    )


def label(text, zone, anchor="right"):
    scene.text(text, role="code").place(zone, anchor=anchor)


def grow(bar, seconds=GROW):
    """The animation each bar carries: drawing it at a steady pace."""
    return bar.animate.create().duration(seconds).easing(Easing.LINEAR)


# Anim.sound anchors a sound to the animation: it starts when the animation
# starts, wherever a delay or a composition puts it, and offset shifts it.
scene.segment("anim_sound")
grid = rows(7)
label("grow = bar.animate.create()", grid[0])
SOUNDS = [
    # label, delay of the bar, the animation, second of its sound, volume
    ("grow.sound(POP)", 0.0, lambda bar: grow(bar).sound(POP), 0.0, 1.0),
    ("grow.sound(POP, volume=0.4)", 0.0, lambda bar: grow(bar).sound(POP, volume=0.4), 0.0, 0.4),
    ("grow.delay(0.4).sound(POP)", 0.4, lambda bar: grow(bar).delay(0.4).sound(POP), 0.4, 1.0),
    ("grow.sound(POP, offset=0.4)", 0.0, lambda bar: grow(bar).sound(POP, offset=0.4), 0.4, 1.0),
    ("grow.sound(POP, offset=-0.3)", 0.0, lambda bar: grow(bar).sound(POP, offset=-0.3), -0.3, 1.0),
]
for index, (text, delay, build, at, volume) in enumerate(SOUNDS, start=1):
    label(text, grid[2 * index])
    strip = Strip(grid[2 * index + 1])
    bar = strip.bar(LEAD + delay)
    strip.start(*strip.sound(LEAD + at, volume))
    scene.play(build(bar))
    strip.finish()

# In a sequence, each animation brings its own sound.
label("sequence(grow.sound(POP), grow.sound(POP))", grid[12])
strip = Strip(grid[13])
half = GROW / 2
first, second = strip.bar(LEAD, half), strip.bar(LEAD + half, half)
strip.start(*strip.sound(LEAD), *strip.sound(LEAD + half))
scene.play(sequence(grow(first, half).sound(POP), grow(second, half).sound(POP)))
strip.finish()

# scene.media.sfx places a sound at an absolute second, the cursor by
# default. It never moves the cursor, and it can sit before it.
scene.segment("sfx")
grid = rows(5)
label("t = scene.cursor", grid[0])
PLACED = [
    ("scene.media.sfx(POP)", lambda t: scene.media.sfx(POP), 0.0, 1.0),
    ("scene.media.sfx(POP, volume=0.4)", lambda t: scene.media.sfx(POP, volume=0.4), 0.0, 0.4),
    ("scene.media.sfx(POP, at=t + 0.6)", lambda t: scene.media.sfx(POP, at=t + 0.6), 0.6, 1.0),
    ("scene.media.sfx(POP, at=t - 0.3)", lambda t: scene.media.sfx(POP, at=t - 0.3), -0.3, 1.0),
]
for index, (text, place, at, volume) in enumerate(PLACED, start=1):
    label(text, grid[2 * index])
    strip = Strip(grid[2 * index + 1])
    strip.start(*strip.sound(LEAD + at, volume))
    place(scene.cursor)
    strip.finish()

# A transition's sound starts with the transition, at the start of the
# segment it enters. Sounds on real animations: typing under a write, and a
# pop on each dot of a stagger.
scene.segment("in_use", Transition.slide(0.6, "left", sound=WHOOSH))
page = scene.layout.zones(Zones.rows(["1fr", "1fr", "2fr", "1fr", "2fr"], padding="page_padding"))
label('Transition.slide(0.6, "left", sound=WHOOSH)', page[0], "left")
label("words.animate.write().sound(TYPING)", page[1], "left")
words = scene.text("Every sound follows its animation").scale_to(1.6).place(page[2], anchor="left")
label("stagger(*[dot.animate.fade_in().sound(POP) for dot in dots], each=0.25)", page[3], "left")
stage = page[4]
dots = [
    scene.geometry.circle(0.45).move_to(stage.left + 0.45 + index * (stage.width - 0.9) / 5, stage.center[1])
    for index in range(6)
]
scene.wait(0.8)
scene.play(words.animate.write().duration(1.2).sound(TYPING))
scene.play(stagger(*[dot.animate.fade_in().duration(0.2).sound(POP) for dot in dots], each=0.25))
scene.wait(0.6)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # A sound starting in some rows, then each segment settled, and the last
    # segment sliding in, writing and popping.
    scene.snapshots(snapshots, [0.6, 3.8, 5.2, 5.9, 7.75, 8.35, 12.4, 12.9, 13.95, 14.3, 15.4, 16.6, 17.6])
else:
    scene.render()

Signals

A track's loudness, bands, hits and beats as signals, and the sizes, opacities, updaters, falloffs and readouts they drive.

Signals
View on GitHubgaanim examples/audio/signals.py
Code · 155 lines
"""Audio signals: the loudness, bands, hits and onsets of a track, and the properties they drive.

``rhythm.ogg`` is a quiet bar (its first two seconds) and then a beat at 120
BPM: a kick on every beat, a hi-hat between them and a bass line. The first
segment shows each signal on a meter and the onsets on a strip of the track;
the second plays the track again and lets the same signals drive scale,
opacity, rotation, jitter, color, position and a readout. Signals read 0
before the track plays and after it ends.
"""

import os

from gaanim import Falloff, Scene, Updater, Zones, computed

RHYTHM = "assets/rhythm.ogg"
LEAD = 0.3
HOLD = 0.3

scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")


def rows(count, label_share="5fr", stage_share="3fr"):
    """``count`` rows of a code column and a stage column, padded from the frame."""
    return scene.layout.zones(
        Zones.grid(rows=count, columns=[label_share, stage_share], column_gap="48px", padding="page_padding")
    )


def label(text, zone):
    scene.text(text, role="code").place(zone, anchor="right")


def meter(zone, signal):
    """A horizontal meter across ``zone`` whose fill follows ``signal``, growing to the right."""
    mask = scene.geometry.rounded_rect(zone.width, 0.36, 0.18).no_fill().stroke(rule, 0.03).move_to(*zone.center)
    scene.geometry.fill_level(mask, accent, signal, direction="right")


def play(music):
    """Play the whole track from the cursor, with a moment before and after."""
    scene.wait(LEAD)
    scene.play(music)
    scene.wait(music.duration + HOLD)


# Each signal is a Computed of the time from 0 to 1. level follows the
# loudness (smoothing keeps a share of the previous frame; range_db measures
# in decibels), band one range of frequencies, and pulse jumps to 1 at each
# onset and fades out over decay seconds. Here fill_level draws them.
scene.segment("signals")
grid = rows(10)
music = scene.media.audio(RHYTHM)
label("music = scene.media.audio(RHYTHM)", grid[0])
SIGNALS = [
    ("level = music.level()", music.level()),
    ("smooth = music.level(smoothing=0.8)", music.level(smoothing=0.8)),
    ("loud = music.level(range_db=24)", music.level(range_db=24)),
    ("bass = music.band(20, 150)", music.band(20, 150)),
    ("highs = music.band(4000, 10000)", music.band(4000, 10000)),
    ("hits = music.pulse()", music.pulse()),
    ("kicks = music.pulse(0.4, low=40, high=120)", music.pulse(0.4, low=40, high=120)),
]
for index, (text, signal) in enumerate(SIGNALS, start=1):
    label(text, grid[2 * index])
    meter(grid[2 * index + 1], signal)

# beats() lists the onsets in seconds of the track, here on a strip of its
# eight seconds with a playhead; with low and high, only those in that band.
start = scene.cursor + LEAD
for row, (text, onsets) in enumerate(
    [("music.beats()", music.beats()), ("music.beats(low=40, high=120)", music.beats(low=40, high=120))], start=8
):
    label(text, grid[2 * row])
    zone = grid[2 * row + 1]
    (left, y), (right, _) = zone.point("left"), zone.point("right")
    scene.geometry.line(left, y, right, y).stroke(rule, 0.03)
    for onset in onsets:
        x = left + (right - left) * onset / music.duration
        scene.geometry.rect(0.03, 0.4).fill(accent).move_to(x, y)
    head = scene.geometry.dot(0.08).fill(foreground)
    progress = computed(lambda t: min(max(t - start, 0.0), music.duration) / music.duration, inputs=[scene.time])
    head.move_to(computed(lambda p: left + (right - left) * p, inputs=[progress]), y)
play(music)

# The same signals, from a second play of the track, drive anything that
# takes a number that changes with time.
scene.segment("driving")
grid = rows(8, "6fr", "2fr")
music = scene.media.audio(RHYTHM)
level, smooth, loud = music.level(), music.level(smoothing=0.8), music.level(range_db=24)
bass, highs = music.band(20, 150), music.band(4000, 10000)
hits, kicks = music.pulse(), music.pulse(0.4, low=40, high=120)


def stage(index):
    """The stage zone of row ``index`` and its center."""
    zone = grid[2 * index + 1]
    return zone, zone.center


label("disc.scale_to(bass)", grid[0])
zone, center = stage(0)
scene.geometry.circle(0.42).move_to(*center).scale_to(bass)

label("ring.opacity(kicks)", grid[2])
zone, center = stage(1)
scene.geometry.circle(0.36).no_fill().stroke(accent, 0.08).move_to(*center).opacity(kicks)

label("star.add_updater(Updater.rotate(level))", grid[4])
zone, center = stage(2)
star = scene.geometry.star(5, 0.45, 0.2).move_to(*center)
star.add_updater(Updater.rotate(level))

label("square.add_updater(Updater.wiggle(rotation=hits))", grid[6])
zone, center = stage(3)
square = scene.geometry.square(0.6).move_to(*center)
square.add_updater(Updater.wiggle(rotation=hits, frequency=6.0))


def dots(zone):
    cells = zone.split(Zones.columns(6, gap="12px"))
    return scene.geometry.group([scene.geometry.circle(0.2).move_to(*cell.center) for cell in cells])


label('dots.drive("fill",\nFalloff.source(bass).gradient(rule, accent))', grid[8])
dots(stage(4)[0]).drive("fill", Falloff.source(bass).gradient(rule, accent))

label('dots.drive("scale",\nFalloff.source(highs).remap(0.4, 1.4))', grid[10])
dots(stage(5)[0]).drive("scale", Falloff.source(highs).remap(0.4, 1.4))

label("ball.move_to(x, computed(lambda l: y + 0.6 * l,\n                         inputs=[loud]))", grid[12])
zone, (x, y) = stage(6)
y = zone.bottom + 0.1
scene.geometry.line(zone.left, y - 0.1, zone.right, y - 0.1).stroke(rule, 0.03)
scene.geometry.dot(0.1).move_to(x, computed(lambda l: y + 0.6 * l, inputs=[loud]))

label('scene.viz.readout(smooth, format=".2f")', grid[14])
zone, center = stage(7)
scene.viz.readout(smooth, format=".2f").place(zone)

play(music)

# The quiet bar, a kick, just after it, and the hi-hat between two kicks, in
# each segment.
SEEKS = [segment * (LEAD + 8.0 + HOLD) + t for segment in range(2) for t in (1.5, 4.32, 4.45, 4.55)]

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, SEEKS)
else:
    scene.render()

Visualizers

Equalizer shapes, layouts and fills, and the spectrum, waveform and samples of a track they draw.

Visualizers
View on GitHubgaanim examples/audio/visualizers.py
Code · 184 lines
"""Audio visualizers: equalizer shapes, layouts and fills, and the spectrum, waveform and samples they draw.

``scene.viz.equalizer`` draws one element per signal, each as long as its
value. Every segment plays a few seconds of drums and bass under a set of
tiles, and each tile is labelled with what it changes in the call shown at
the top of the segment. The last segment compares the signals a track
offers: its spectrum, its recent loudness, its sound wave and, through a
shader, its spectrogram.
"""

import math
import os

from gaanim import Falloff, PostProcess, Scene, Zones, computed

BEAT = "assets/beat.ogg"  # six seconds of drums and bass at 120 BPM
SECONDS = 3.0
LEAD = 0.3
HOLD = 0.3

scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
chart = scene.canvas.color("chart")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")
warning = scene.canvas.color("warning")
danger = scene.canvas.color("danger")


def tiles(header, labels, columns=2):
    """A header row with the shared call, then one tile per label: a stage above its code."""
    page = scene.layout.zones(Zones.rows(["1fr", "11fr"], gap="24px", padding="page_padding"))
    scene.text(header, role="code").place(page[0], anchor="left")
    rows = len(labels) // columns
    grid = page[1].split(Zones.grid(rows=rows, columns=columns, row_gap="36px", column_gap="48px"))
    stages = []
    for cell, text in zip(grid, labels):
        stage, caption = cell.split(Zones.rows(["3fr", "1fr"], gap="36px"))
        scene.text(text, role="code").place(caption, anchor="top")
        stages.append(stage)
    return stages


def box(zone):
    """The keyword arguments that fit an equalizer row in ``zone``."""
    return {"center": zone.center, "width": zone.width, "height": zone.height}


def play(music):
    """Play the clip after a moment, then hold its last frame."""
    scene.wait(LEAD)
    scene.play(music)
    scene.wait(HOLD)


# shape picks what each element is: a bar, a capsule, a dot at its tip, one
# line through the tips or that line filled down. A function makes your own
# element, stretched along the slot or, with stretch=False, scaled whole.
def star(scene, index, count):
    return scene.geometry.star(5, 0.3, 0.13)


scene.segment("shapes")
music = scene.media.audio(BEAT, duration=SECONDS)
spectrum = music.spectrum(16, smoothing=0.3)
SHAPES = [
    ('shape="bar"', {"shape": "bar"}),
    ('shape="capsule"', {"shape": "capsule"}),
    ('shape="dot"', {"shape": "dot"}),
    ('shape="line"', {"shape": "line"}),
    ('shape="area"', {"shape": "area"}),
    # Scaled whole, the stars stay in their slots when no taller than a slot is wide.
    ("shape=star, stretch=False", {"shape": star, "stretch": False, "height": 0.6}),
]
stages = tiles("spectrum = music.spectrum(16, smoothing=0.3)", [text for text, _ in SHAPES])
for stage, (_, options) in zip(stages, SHAPES):
    scene.viz.equalizer(spectrum, fill=accent, **{**box(stage), **options})
play(music)


# layout places the elements: in a row growing up, mirrored about a middle
# line, or around a circle growing out. A function returns where each one
# starts and the angle it grows at. gap is the share of each slot left empty.
scene.segment("layouts")
music = scene.media.audio(BEAT, duration=SECONDS)
spectrum = music.spectrum(24, smoothing=0.3)
LAYOUTS = ['layout="row"', 'layout="mirror"', 'layout="radial"',
           'layout="radial",\nshape="dot"', "layout=arc", 'layout="row",\ngap=0.7']
stages = tiles("scene.viz.equalizer(spectrum, fill=accent, ...)", LAYOUTS, columns=3)
size = min(stages[2].width, stages[2].height)


def arc(index, count):
    """Start each element on an arch over the fifth stage, growing outward."""
    angle = math.pi * (0.9 - 0.8 * index / (count - 1))
    (x, _), bottom = stages[4].center, stages[4].bottom
    return x + size * 0.45 * math.cos(angle), bottom + size * 0.45 * math.sin(angle), angle


def ring(stage, **options):
    return {"layout": "radial", "center": stage.center, "radius": size * 0.25, "height": size * 0.22, **options}


for stage, options in zip(stages, [
    box(stages[0]),
    {"layout": "mirror", **box(stages[1])},
    ring(stages[2]),
    ring(stages[3], shape="dot"),
    {"layout": arc, "height": size * 0.4, "thickness": 0.07},
    {"gap": 0.7, **box(stages[5])},
]):
    scene.viz.equalizer(spectrum, fill=accent, **options)
play(music)

# fill takes one paint, a list cycled over the elements, a ramp across the
# group or a function of the index; value_colors colors each element by its
# own value instead.
scene.segment("fills")
music = scene.media.audio(BEAT, duration=SECONDS)
spectrum = music.spectrum(16, smoothing=0.3)
FILLS = [
    ("fill=foreground", {"fill": foreground}),
    ("fill=[accent, chart]", {"fill": [accent, chart]}),
    ("fill=Falloff.index()\n.gradient(rule, accent)", {"fill": Falloff.index().gradient(rule, accent)}),
    ("fill=lambda i, n:\naccent if i < n / 4 else rule", {"fill": lambda i, n: accent if i < n / 4 else rule}),
    ("value_colors=[rule, accent]", {"value_colors": [rule, accent]}),
    ("value_colors=\n[success, warning, danger]", {"value_colors": [success, warning, danger]}),
]
stages = tiles('scene.viz.equalizer(spectrum, shape="capsule", ...)', [text for text, _ in FILLS])
for stage, (_, options) in zip(stages, FILLS):
    scene.viz.equalizer(spectrum, shape="capsule", **box(stage), **options)
play(music)

# The signals: a spectrum against the loudest band instead of each band's
# own loud end, or over a range of frequencies; the loudness of the last
# seconds, of the whole track or of a band; and the sound wave itself, which
# a polyline draws. A shader reads the whole spectrogram with audio=.
SPECTROGRAM = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
    // Left two seconds ago, right now; low frequencies at the bottom.
    let energy = gaanim_audio_spectrum(1.0 - uv.y, 2.0 * (1.0 - uv.x));
    return gaanim_scene(uv) * energy;
}
"""

scene.segment("signals")
music = scene.media.audio(BEAT, duration=SECONDS)
stages = tiles("scene.viz.equalizer(signals, fill=accent, ...)", [
    'music.spectrum(24,\nnormalize="global")',
    "music.spectrum(24, low=40,\nhigh=400)",
    'music.waveform(64, span=2.0)\nshape="line", layout="mirror"',
    'music.waveform(64, low=20,\nhigh=150), shape="area"',
    "music.samples(160, span=0.03)\nscene.geometry.polyline(...)",
    "panel.shader_effect(spectrogram)\nPostProcess.shader(..., audio=music)",
])
scene.viz.equalizer(music.spectrum(24, normalize="global"), fill=accent, **box(stages[0]))
scene.viz.equalizer(music.spectrum(24, low=40, high=400), fill=accent, **box(stages[1]))
scene.viz.equalizer(music.waveform(64, span=2.0), shape="line", layout="mirror", fill=accent, **box(stages[2]))
scene.viz.equalizer(music.waveform(64, low=20, high=150), shape="area", fill=accent, **box(stages[3]))

# The wave as an oscilloscope: one point per sample of the last 30 ms.
stage = stages[4]
wave = music.samples(160, span=0.03)
left, (_, middle) = stage.left, stage.center
scene.geometry.polyline([
    (left + stage.width * index / (len(wave) - 1), computed(lambda v: middle + 0.5 * stage.height * v, inputs=[sample]))
    for index, sample in enumerate(wave)
]).stroke(accent, 0.03)

stage = stages[5]
panel = scene.geometry.rect(stage.width, stage.height).fill(accent).no_stroke().move_to(*stage.center)
spectrogram = PostProcess.shader(SPECTROGRAM, audio=music)
panel.shader_effect(spectrogram, margin=0.0)
play(music)

# Each segment on a kick and on the hi-hat after it.
SEEKS = [segment * (LEAD + SECONDS + HOLD) + t for segment in range(4) for t in (2.32, 2.56)]

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, SEEKS)
else:
    scene.render()

Narration

Voiceover blocks timed by their takes, markers in the text, a narration script and a live take recorded while presenting.

Narration
View on GitHubgaanim examples/audio/narration.py
Code · 162 lines
"""Narration: voiceover blocks timed by their takes, markers in the text, a narration script and a live take.

Each segment shows its code on the left, the drawing it animates on the
right, the text being read below (marker words light up as the voice
reaches them) and the block's take as a strip of time. Without a recorded
take, as here, a block lasts as long as its text takes to read at about 150
words per minute and markers sit where their words fall in it; record takes
from the editor's narration panel and the same code follows your voice.
"""

import os

from gaanim import Easing, Scene, Zones

scene = Scene()
SEEKS = []  # moments to capture, collected as the scene is built
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")


def page():
    """The zones of a segment: code, drawing, the text being read and the strip of the take."""
    rows = scene.layout.zones(Zones.rows(["5fr", "2fr", "1fr"], gap="24px", padding="page_padding"))
    code, drawing = rows[0].split(Zones.columns(["3fr", "2fr"], gap="48px"))
    return code, drawing, rows[1], rows[2]


def code(text, zone):
    scene.text(text, role="code").place(zone, anchor="left")


def read(text, zone):
    """The text being read, at the left of ``zone``."""
    return scene.text(text, size=0.5).place(zone, anchor="left")


class Take:
    """A strip of the take of ``vo`` across ``zone``: a bar grows over it and marks show the markers."""

    def __init__(self, vo, zone, spoken):
        # spoken is the Text being read; spoken["word"] selects a word in it.
        (self.left, self.y), (self.right, _) = zone.point("left"), zone.point("right")
        self.vo, self.spoken = vo, spoken
        scene.geometry.line(self.left, self.y, self.right, self.y).stroke(rule, 0.04)
        bar = scene.geometry.line(self.left, self.y, self.right, self.y).stroke(accent, 0.14)
        scene.launch(bar.animate.create().duration(vo.duration).easing(Easing.LINEAR))

    def reach(self, marker):
        """Light up ``marker`` in the text and mark the cursor's place in the take."""
        x = self.left + (self.right - self.left) * (scene.cursor - self.vo.start) / self.vo.duration
        tick = scene.geometry.rect(0.05, 0.5).fill(foreground).move_to(x, self.y)
        scene.launch([tick.animate.fade_in().duration(0.1), self.spoken[marker].animate.fill(accent).duration(0.2)])
        SEEKS.append(scene.cursor + 0.25)


def drawing(zone):
    """A parabola across ``zone`` with a point and a tangent that follow ``x``, from 0 to 1 along it."""
    (cx, _), width, height = zone.center, zone.width * 0.9, zone.height * 0.85
    points = [(cx + width / 2 * u, zone.bottom + height * u * u) for u in (i / 40 - 1 for i in range(81))]
    curve = scene.geometry.polyline(points).no_fill().stroke(foreground, 0.04)
    x = scene.viz.parameter(0.3)
    dot = scene.geometry.point_on_curve(curve, x).scale_to(1.6)
    tangent = scene.geometry.tangent_on_curve(curve, x, length=2.4).stroke(accent, 0.05)
    return curve, x, dot, tangent


# A voiceover block starts at the cursor. Its text is the segment's notes
# unless text= gives it, until() is the time to a marker (a duration that
# ends on it) and wait_until() moves the cursor to it. Leaving the block
# waits for the rest of the take.
NOTES = "A derivative is the slope of a curve at a point."
scene.segment("slope", notes=NOTES)
code_zone, stage, reading, strip = page()
code(
    'scene.segment("slope", notes=NOTES)\n'
    'with scene.voiceover("slope") as vo:\n'
    "    scene.play(curve.animate.create(),\n"
    '        duration=vo.until("slope"))\n'
    '    vo.wait_until("curve")\n'
    "    scene.play(dot.animate.fade_in())\n"
    "    scene.play(tangent.animate.create(),\n"
    '        duration=vo.until("point"))',
    code_zone,
)
curve, x, dot, tangent = drawing(stage)
spoken = read(NOTES, reading)
take_zone, facts_zone = strip.split(Zones.columns(["2fr", "1fr"], gap="48px"))
with scene.voiceover("slope") as vo:
    take = Take(vo, take_zone, spoken)
    # Its length and whether a recorded take backs it are known when it starts.
    code(f"vo.duration == {vo.duration:.1f}\nvo.recorded == {vo.recorded}", facts_zone)
    scene.play(curve.animate.create(), duration=vo.until("slope"))
    take.reach("slope")
    vo.wait_until("curve")
    take.reach("curve")
    scene.play(dot.animate.fade_in().duration(0.3))
    scene.play(tangent.animate.create(), duration=vo.until("point"))
    take.reach("point")
scene.wait(0.5)
SEEKS.append(scene.cursor - 0.1)

# A narration script keeps the texts out of the code: each "## key" section
# of a Markdown file is the text of voiceover("key"). Without the with
# statement, finish() waits for the rest of the take.
scene.segment("script")
code_zone, stage, reading, strip = page()
code(
    'scene.narration_script("assets/script.md")\n'
    'vo = scene.voiceover("tangent")\n'
    'vo.wait_until("moves")\n'
    "scene.play(x.animate.set(0.9),\n"
    '    duration=vo.until("tangent"))\n'
    "vo.finish()",
    code_zone,
)
curve, x, dot, tangent = drawing(stage)
scene.narration_script("assets/script.md")
vo = scene.voiceover("tangent")
section, sentence = reading.split(Zones.rows(2))
code("## tangent", section)
spoken = read(vo.text, sentence)
take = Take(vo, strip, spoken)
scene.play([curve.animate.fade_in(), dot.animate.fade_in(), tangent.animate.fade_in()], duration=0.3)
vo.wait_until("moves")
take.reach("moves")
scene.play(x.animate.set(0.9), duration=vo.until("tangent"))
take.reach("tangent")
vo.finish()
scene.wait(0.5)
SEEKS.append(scene.cursor - 0.1)

# A live take records you presenting: every stop after it becomes a pause
# as long as yours when you recorded, and stays an interactive stop until
# then. The teleprompter shows the script's section named like the segment.
scene.segment("close")
code_zone, stage, reading, strip = page()
code(
    'scene.live_take("talk")\n'
    "for step in steps:\n"
    "    scene.play(step.animate.fade_in())\n"
    "    scene.stop()",
    code_zone,
)
cells = stage.split(Zones.columns(3, gap="24px"))
steps = [scene.geometry.circle(min(cell.width, cell.height) * 0.4).move_to(*cell.center) for cell in cells]
section, sentence = reading.split(Zones.rows(2))
code("## close", section)
read("That is all for today.", sentence)
scene.live_take("talk")
for step in steps:
    scene.play(step.animate.fade_in().duration(0.4))
    scene.stop()
    SEEKS.append(scene.cursor - 0.02)
scene.wait(0.5)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, SEEKS)
else:
    scene.render()