PythonHere 0.2.2 release intro

PythonHere 0.2.2 release intro#

The intro was generated with %%there ai to show the PythonHere 0.2.x release features as a short audiovisual app.

Watch the result as a YouTube Short: https://www.youtube.com/shorts/ExN0YnTljVc

Connect#

%load_ext pythonhere
%connect-there

Prompt used to generate the intro#

%%there ai --prompts midi
You are the PythonHere 0.2.x release.
Express yourself as a short audiovisual demoscene-style intro.

Goal: generate a compact audiovisual intro.

Your release features:

```python
announcements = [
    {
        "title": "PythonHere 0.2.*",
        "subtitle": "the stack wakes again"
    },
    {
        "title": "GitHub Releases",
        "subtitle": "APK builds are back"
    },
    {
        "title": "%%there get {variable}",
        "subtitle": "get remote variables"
    },
    {
        "title": "%%there download {file}",
        "subtitle": "pull files from the target"
    },
    {
        "title": "%%there ai",
        "subtitle": "prompt -> cell"
    },
    {
        "title": "Generate -> Edit -> Run",
        "subtitle": "generated cells stay under your control"
    },
    {
        "title": "%%there ai --prompts masterpiece",
        "subtitle": "custom context / custom style"
    },
    {
        "title": "%%there ai --fix",
        "subtitle": "failure becomes feedback"
    },
]
```

Creative direction:
Make it feel like a compact demoscene intro built out of Kivy itself.

Use:

* dark background
* neon / terminal / retro-futurist mood
* rhythmic motion
* Kivy widgets used playfully as visual objects
* text effects
* short readable text beats
* autoplay when ready
* endless loop after the intro sequence
* single feature display at a time

Title is: PythonHere 0.2.x.

Make the version beat-synced:

* show `0.2.0`
* then `0.2.1`
* then `0.2.2`
* then `0.2.x`
* repeat this pattern every 16 sequencer steps

Make `Python` and `Here` with separate colors:

* Python: `#306998`
* Here: `#FFD43B`
* version: smaller cyan/white text

Core timing model:
Use one explicit 8-second / 64-step timing grid.

Constants must be:

```python
INTRO_SECONDS = 8.0
STEPS_PER_LOOP = 64
TICK_SECONDS = INTRO_SECONDS / STEPS_PER_LOOP
VISUAL_FPS = 30.0

FEATURES_PER_LOOP = 8
FEATURE_STEPS = STEPS_PER_LOOP // FEATURES_PER_LOOP  # 8

BLOCKS_PER_LOOP = 4
BLOCK_STEPS = STEPS_PER_LOOP // BLOCKS_PER_LOOP      # 16
```

Everything must derive from this sequencer grid:

* loop step: `0..63`
* musical block: `step // BLOCK_STEPS`
* local block step: `step % BLOCK_STEPS`
* feature card: `step // FEATURE_STEPS`
* beat marker: `step % BLOCK_STEPS`
* version character: `(step % BLOCK_STEPS) // 4`

The intro must be perfectly looped in 8 seconds:

* 64 sequencer steps total
* 8 feature cards total
* each feature card lasts exactly 8 sequencer steps
* 4 musical blocks total
* each musical block lasts exactly 16 sequencer steps
* loop returns exactly to the initial visual and musical state at step 0

Sequencer / MIDI clock:
The intro should be built around a live MIDI sequencer.

The sequencer step is the master state.
The Kivy Clock interval that calls `midi_tick` is the sequencer clock.
`midi_tick` advances exactly one integer step each tick.
Visuals must follow the sequencer state.

Do not derive visual state directly from `loop_start_time`.
Do not use `phase * len(announcements)` for feature cards.
Do not use independent wall-clock animation as the master.

Keep this state:

```python
self.step
self.last_played_step
self.last_tick_time
```

Use this helper for visual timing:

```python
def _loop_position(self):
    elapsed_since_tick = max(0.0, time.perf_counter() - self.last_tick_time)
    micro = min(0.999, elapsed_since_tick / TICK_SECONDS)

    step_float = self.last_played_step + micro
    step_float %= STEPS_PER_LOOP

    step = int(step_float)
    phase = step_float / STEPS_PER_LOOP

    return step, micro, phase
```

In `midi_tick`:

* if stopped, return `False`
* at the start of each sequencer tick, decrement active note lifetimes
* send `note_off` for notes whose `remaining_steps` reaches zero
* then generate MIDI events for the current integer `step`
* then send the new `note_on` events
* then add those notes to the active note list with `remaining_steps`
* then set `last_played_step = played_step`
* then set `last_tick_time = time.perf_counter()`
* then advance `step = (step + 1) % STEPS_PER_LOOP`

In `visual_tick`:

* never schedule MIDI
* never play MIDI
* never stop MIDI
* only read sequencer state
* call `_loop_position()`
* derive all visual state from `visual_step`, `micro`, and `phase`

Use this visual timing pattern:

```python
visual_step, micro, phase = self._loop_position()

feature_index = min(
    len(announcements) - 1,
    visual_step // FEATURE_STEPS,
)

local_feature_phase = (
    (visual_step % FEATURE_STEPS) + micro
) / FEATURE_STEPS

active16 = visual_step % BLOCK_STEPS

version_char = ["0", "1", "2", "x"][
    (visual_step % BLOCK_STEPS) // 4
]
```

Requirements:

* The intro should be built around a live MIDI sequencer.
* The sequencer state is the master clock.
* Visuals follow the sequencer state.
* Portrait Android mode.
* Text should always fit screen and containers.
* The result should not look like a normal app screen.
* Intro should be perfectly looped in 8 seconds.
* Should show all 8 features in 8 seconds.
* Should loop to exactly the initial state.
* Avoid audio slowdown by keeping `midi_tick` very lightweight.

Audio:
Generate and play a live MIDI looping synthpop positive rhythmic tune.

The tune must follow the 64-step grid:

* 64 steps per loop
* 4 musical blocks
* 16 steps per musical block
* use `block = step // BLOCK_STEPS`
* use `local = step % BLOCK_STEPS`
* drums, bass, lead, and chords should all loop exactly at step 64
* keep the MIDI event count per tick small
* avoid heavy computation inside `midi_tick`

Note lifetime:
Do not use `Clock.schedule_once` for every `note_off`.

Instead, keep a list of active notes with `remaining_steps`.

At the start of each sequencer tick:

* decrement `remaining_steps`
* send `note_off` for notes whose `remaining_steps` reaches zero

Then send new `note_on` events and add them to the active note list.

This keeps MIDI event scheduling deterministic and avoids many tiny callbacks.

Kivy visual concept:
Use ordinary Kivy widgets in extraordinary ways.

For example:

* Labels as glowing title cards
* Buttons as pulsing blocks or beat markers
* Sliders as oscilloscopes
* ProgressBars as equalizers
* BoxLayouts as moving panels
* canvas lines, grids, particles, waves, scanlines, or glow-like effects

The result should not look like a normal app screen.
It should look like a tiny audiovisual intro made from the runtimes own UI system.

Kivy scheduling:
Use Kivy Clock carefully.

Allowed:

* one `Clock.schedule_interval` for the MIDI sequencer tick
* one `Clock.schedule_interval` for lightweight visual refresh

Not allowed:

* many scheduled callbacks
* `Clock.schedule_once` for every MIDI note-off
* rebuilding layouts every frame
* creating/destroying widgets during animation
* recalculating installed distributions during animation
* calling `texture_update` every frame
* scheduling MIDI events from the visual update function
* creating new canvas instructions during animation
* creating large temporary lists during animation

Performance:

* `midi_tick` must be extremely lightweight
* package scanning must happen once before animation starts
* runtime ticker string must be built once before animation starts
* widgets must be created once
* canvas instructions must be created once
* animation should update existing widget properties and existing canvas instructions only
* visual update may set existing `Color.rgba`, `Line.points`, `Rectangle.pos`, `Rectangle.size`, `Label.text`, `Label.opacity`, `Slider.value`, and `ProgressBar.value`
* avoid expensive work in both scheduled callbacks
* never let visual effects slow down MIDI timing

Effects:
Use canvas for effects.

Runtime scroller:
Create a continuous demoscene-style ticker at the bottom or edge of the screen.

The scroller must use real runtime information, not hardcoded fake package versions.

Build it dynamically once before animation starts:

* Use `sys.version_info` for the real Python interpreter version
* Use `[f"{dist.metadata['Name']}=={dist.version}" for dist in distributions()]` for installed packages
* Format the ticker like: `PYTHON==3.x.x | Kivy==2.3.1 | cffi==... | ...`

Ticker timing:

* ticker starts outside the right edge of the screen
* text is not visible at the first frame
* ticker moves from right to left
* ticker position is derived from sequencer `phase`
* ticker returns to exactly the initial outside-right position when `phase` wraps to `0`
* perfect loop sync is more important than showing every package clearly
* if the ticker text is very long, it does not need to fully reveal every package in one loop

Use this ticker motion model:

```python
ticker.x = self.ui.width - phase * (self.ui.width + self.ui._ticker_width)
```

Generated intro code#

%%there
# Generated locally by %%there ai. Review before running.
from importlib.metadata import distributions
import math
import sys
import time

from kivy.clock import Clock
from kivy.graphics import Color, Ellipse, Line, Rectangle
from kivy.lang import Builder
from kivy.logger import Logger
from kivy.metrics import dp, sp
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.popup import Popup
from kivy.uix.progressbar import ProgressBar
from kivy.uix.label import Label

from midistream import Synthesizer, MIDIException, ReverbPreset
from midistream.helpers import (
    Control,
    midi_control_change,
    midi_instruments,
    midi_note_off,
    midi_note_on,
    midi_program_change,
)

INTRO_SECONDS = 8.0
STEPS_PER_LOOP = 64
TICK_SECONDS = INTRO_SECONDS / STEPS_PER_LOOP
VISUAL_FPS = 30.0

FEATURES_PER_LOOP = 8
FEATURE_STEPS = STEPS_PER_LOOP // FEATURES_PER_LOOP  # 8

BLOCKS_PER_LOOP = 4
BLOCK_STEPS = STEPS_PER_LOOP // BLOCKS_PER_LOOP      # 16

announcements = [
    {"title": "PythonHere 0.2.*", "subtitle": "the stack wakes again"},
    {"title": "GitHub Releases", "subtitle": "APK builds are back"},
    {"title": "%%there get {variable}", "subtitle": "get remote variables"},
    {"title": "%%there download {path}", "subtitle": "pull files from the target"},
    {"title": "%%there ai", "subtitle": "prompt -> cell"},
    {"title": "Generate -> Edit -> Run", "subtitle": "generated cells stay under your control"},
    {"title": "%%there ai --prompts masterpiece", "subtitle": "custom context / custom style"},
    {"title": "%%there ai --fix", "subtitle": "failure becomes feedback"},
]

try:
    previous_intro = globals().get("pythonhere_intro_controller")
    if previous_intro is not None:
        previous_intro.stop(close_midi=False)
except Exception:
    Logger.exception("PythonHere: Could not stop previous intro")

if "midistream_active_notes" not in globals():
    midistream_active_notes = set()
if "midistream_used_channels" not in globals():
    midistream_used_channels = set()

installed_runtime_packages = [f"{dist.metadata['Name']}=={dist.version}" for dist in distributions()]
pythonhere_intro_ticker_text = (
    f"PYTHON=={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
    + " | "
    + " | ".join(installed_runtime_packages)
)

pythonhere_intro_state = {
    "ok": False,
    "stage": "initializing",
    "message": "Building PythonHere 0.2.x intro",
    "error": None,
}


def get_synthesizer():
    global synthesizer
    if "synthesizer" not in globals() or synthesizer is None:
        synthesizer = Synthesizer()
    return synthesizer


class IntroRoot(FloatLayout):
    pass


KV = """
#:import dp kivy.metrics.dp
#:import sp kivy.metrics.sp

<IntroRoot>:
    Label:
        id: deck_label
        text: "LIVE GRID 64 / MIDI CLOCK / KIVY CORE"
        size_hint: 0.94, None
        height: dp(22)
        pos_hint: {"center_x": 0.5, "top": 0.998}
        color: 0.25, 0.95, 1.0, 0.82
        font_size: sp(10)
        bold: True
        halign: "center"
        valign: "middle"
        text_size: self.size

    BoxLayout:
        id: title_band
        orientation: "horizontal"
        padding: dp(4), 0
        spacing: dp(2)
        size_hint: 0.96, None
        height: dp(74)
        pos_hint: {"center_x": 0.5, "top": 0.965}

        Label:
            id: python_label
            text: "Python"
            color: 0.188, 0.412, 0.596, 1
            font_size: sp(32)
            bold: True
            halign: "right"
            valign: "middle"
            text_size: self.size
            size_hint_x: 0.42

        Label:
            id: here_label
            text: "Here"
            color: 1.0, 0.831, 0.231, 1
            font_size: sp(32)
            bold: True
            halign: "left"
            valign: "middle"
            text_size: self.size
            size_hint_x: 0.30

        Label:
            id: version_label
            text: "0.2.0"
            color: 0.65, 1.0, 1.0, 1
            font_size: sp(18)
            bold: True
            halign: "left"
            valign: "middle"
            text_size: self.size
            size_hint_x: 0.28

    BoxLayout:
        id: feature_panel
        orientation: "vertical"
        padding: dp(12), dp(8)
        spacing: dp(4)
        size_hint: 0.92, 0.30
        pos_hint: {"center_x": 0.5, "center_y": 0.595}
        canvas.before:
            Color:
                rgba: 0.02, 0.09, 0.12, 0.78
            Rectangle:
                pos: self.pos
                size: self.size
            Color:
                rgba: 0.0, 0.85, 1.0, 0.28
            Line:
                rectangle: self.x, self.y, self.width, self.height
                width: 1.2

        Label:
            id: feature_title
            text: ""
            color: 0.70, 1.0, 1.0, 1
            font_size: sp(22)
            bold: True
            halign: "center"
            valign: "middle"
            text_size: self.width - dp(18), self.height
            shorten: True
            shorten_from: "right"
            size_hint_y: 0.58

        Label:
            id: feature_subtitle
            text: ""
            color: 1.0, 0.88, 0.42, 1
            font_size: sp(15)
            halign: "center"
            valign: "middle"
            text_size: self.width - dp(18), self.height
            shorten: True
            shorten_from: "right"
            size_hint_y: 0.42

    BoxLayout:
        id: osc_bank
        orientation: "vertical"
        spacing: dp(4)
        size_hint: 0.88, None
        height: dp(86)
        pos_hint: {"center_x": 0.5, "center_y": 0.315}

        Slider:
            id: osc_a
            min: 0
            max: 100
            value: 0

        Slider:
            id: osc_b
            min: 0
            max: 100
            value: 0

        Slider:
            id: osc_c
            min: 0
            max: 100
            value: 0

    BoxLayout:
        id: meter_bank
        orientation: "vertical"
        spacing: dp(2)
        size_hint: 0.88, None
        height: dp(70)
        pos_hint: {"center_x": 0.5, "y": 0.145}

    BoxLayout:
        id: beat_row
        orientation: "horizontal"
        spacing: dp(2)
        size_hint: 0.94, None
        height: dp(34)
        pos_hint: {"center_x": 0.5, "y": 0.085}

    Label:
        id: status_label
        text: "arming sequencer"
        size_hint: 0.96, None
        height: dp(24)
        pos_hint: {"center_x": 0.5, "y": 0.048}
        color: 0.55, 1.0, 0.84, 0.9
        font_size: sp(10)
        bold: True
        halign: "center"
        valign: "middle"
        text_size: self.size
        shorten: True
        shorten_from: "right"

    Label:
        id: ticker
        text: ""
        size_hint: None, None
        height: dp(24)
        x: root.width
        y: dp(3)
        color: 0.20, 1.0, 0.58, 0.92
        font_size: sp(10)
        bold: True
        halign: "left"
        valign: "middle"
        text_size: None, self.height

IntroRoot:
"""


def build_midi_pattern():
    roots = [48, 45, 41, 43]
    chords = [
        [60, 64, 67],
        [57, 60, 64],
        [53, 57, 60],
        [55, 59, 62],
    ]
    leads = [
        [72, 74, 76, 79, 76, 74],
        [69, 72, 76, 81, 76, 72],
        [65, 69, 72, 77, 72, 69],
        [67, 71, 74, 79, 83, 79],
    ]
    lead_slots = [2, 5, 7, 10, 13, 15]
    bass_slots = [0, 3, 6, 8, 11, 14]
    pattern = [[] for _ in range(STEPS_PER_LOOP)]

    for step in range(STEPS_PER_LOOP):
        block = step // BLOCK_STEPS
        local = step % BLOCK_STEPS
        root_note = roots[block]

        if local in (0, 8):
            pattern[step].append((9, 36, 108, 1))
        if local in (4, 12):
            pattern[step].append((9, 38, 96, 1))
        if local % 2 == 0:
            pattern[step].append((9, 42, 62, 1))
        if local == 14:
            pattern[step].append((9, 46, 72, 1))

        if local in bass_slots:
            bass_note = root_note if local in (0, 6, 8, 14) else root_note + 7
            pattern[step].append((0, bass_note, 92, 2))

        if local in (0, 8):
            for chord_note in chords[block]:
                pattern[step].append((2, chord_note, 50, 7))

        if local in lead_slots:
            lead_index = lead_slots.index(local)
            pattern[step].append((1, leads[block][lead_index], 78, 2))

    return pattern


class PythonHereIntroController:
    def __init__(self, ui, ticker_text, cards):
        self.ui = ui
        self.cards = cards
        self.ticker_text = ticker_text
        self.step = 0
        self.last_played_step = 0
        self.last_tick_time = time.perf_counter()
        self.running = False
        self.midi_enabled = True
        self.midi_error = None
        self.active_notes = []
        self.used_channels = {0, 1, 2, 9}
        self.midi_event = None
        self.visual_event = None
        self.current_feature_index = None
        self.current_version_text = None
        self.note_pattern = build_midi_pattern()
        self.beat_buttons = []
        self.meters = []
        self.particle_items = []
        self.grid_lines = []
        self.scan_color = None
        self.scan_rect = None
        self.wave_color = None
        self.wave_line = None
        self.bg_rect = None
        self.bg_color = None
        self._setup_widgets()
        self._setup_canvas()
        self.ui.bind(pos=self._resize_canvas, size=self._resize_canvas)
        self._resize_canvas()

    def _setup_widgets(self):
        self.ui.ids.ticker.text = self.ticker_text
        self.ui.ids.ticker.texture_update()
        self.ui._ticker_width = max(1, int(self.ui.ids.ticker.texture_size[0]))
        self.ui.ids.ticker.width = self.ui._ticker_width

        for index in range(16):
            btn = Button(
                text=f"{index:02d}",
                font_size=sp(9),
                bold=True,
                background_normal="",
                background_down="",
                color=(0.65, 1.0, 1.0, 0.85),
            )
            btn.background_color = (0.02, 0.12, 0.16, 0.70)
            self.ui.ids.beat_row.add_widget(btn)
            self.beat_buttons.append(btn)

        for index in range(8):
            meter = ProgressBar(max=100, value=0)
            self.ui.ids.meter_bank.add_widget(meter)
            self.meters.append(meter)

    def _setup_canvas(self):
        with self.ui.canvas.before:
            self.bg_color = Color(0.005, 0.008, 0.018, 1.0)
            self.bg_rect = Rectangle(pos=self.ui.pos, size=self.ui.size)

            for _ in range(10):
                color = Color(0.0, 0.45, 0.58, 0.10)
                line = Line(points=[0, 0, 0, 0], width=0.8)
                self.grid_lines.append((color, line))

            self.wave_color = Color(0.0, 0.95, 1.0, 0.52)
            self.wave_line = Line(points=[], width=1.4)

            self.scan_color = Color(0.2, 1.0, 0.75, 0.10)
            self.scan_rect = Rectangle(pos=(0, 0), size=(1, dp(3)))

            for i in range(28):
                color = Color(0.20, 1.0, 0.75, 0.0)
                dot = Ellipse(pos=(0, 0), size=(dp(2), dp(2)))
                self.particle_items.append((color, dot, i))

    def _resize_canvas(self, *args):
        w = max(1.0, float(self.ui.width))
        h = max(1.0, float(self.ui.height))
        self.bg_rect.pos = self.ui.pos
        self.bg_rect.size = self.ui.size

        for i, pair in enumerate(self.grid_lines):
            color, line = pair
            if i < 5:
                x = w * (i + 1) / 6.0
                line.points = [x, 0, x, h]
            else:
                y = h * (i - 4) / 6.0
                line.points = [0, y, w, y]
            color.rgba = (0.0, 0.55, 0.70, 0.08)

    def _program_synth(self):
        global midistream_used_channels
        synth = get_synthesizer()
        synth.volume = 86
        synth.reverb = ReverbPreset.ROOM
        setup = []
        setup += midi_program_change(38, channel=0)
        setup += midi_program_change(81, channel=1)
        setup += midi_program_change(88, channel=2)
        setup += midi_control_change(Control.volume, 104, channel=0)
        setup += midi_control_change(Control.volume, 92, channel=1)
        setup += midi_control_change(Control.volume, 70, channel=2)
        setup += midi_control_change(Control.pan, 44, channel=0)
        setup += midi_control_change(Control.pan, 82, channel=1)
        setup += midi_control_change(Control.pan, 64, channel=2)
        synth.write(setup)
        midistream_used_channels.update(self.used_channels)

    def _safe_note_off_list(self, notes):
        global midistream_active_notes
        if not notes or not self.midi_enabled:
            return
        cmd = []
        for channel, note in notes:
            cmd += midi_note_off(note, channel=channel, velocity=0)
        get_synthesizer().write(cmd)
        for channel, note in notes:
            midistream_active_notes.discard((channel, note))

    def all_sound_off(self):
        global midistream_active_notes
        if not self.midi_enabled:
            self.active_notes = []
            midistream_active_notes.clear()
            return
        try:
            off_notes = [(channel, note) for channel, note, _remaining in self.active_notes]
            if off_notes:
                self._safe_note_off_list(off_notes)
            cmd = []
            for channel in sorted(self.used_channels):
                cmd += midi_control_change(Control.all_sound_off, 0, channel=channel)
            get_synthesizer().write(cmd)
        except Exception:
            Logger.exception("PythonHere: Could not silence intro MIDI")
        self.active_notes = []
        midistream_active_notes.clear()

    def start(self):
        pythonhere_intro_state.update(
            ok=True,
            stage="running",
            message="PythonHere 0.2.x intro running",
            error=None,
        )
        self.running = True
        try:
            self._program_synth()
            self.ui.ids.status_label.text = "MIDI locked / 64 step loop / visual clock slaved"
        except Exception as exc:
            self.midi_enabled = False
            self.midi_error = f"{type(exc).__name__}: {exc}"
            pythonhere_intro_state.update(
                ok=False,
                stage="midi_init",
                message="Visual intro running without MIDI",
                error=self.midi_error,
            )
            self.ui.ids.status_label.text = "MIDI init failed / visual clock continues"
            Logger.exception("PythonHere: Could not initialize MIDI intro")

        self.visual_tick(0)
        self.midi_tick(0)
        self.midi_event = Clock.schedule_interval(self.midi_tick, TICK_SECONDS)
        self.visual_event = Clock.schedule_interval(self.visual_tick, 1.0 / VISUAL_FPS)

    def stop(self, close_midi=False):
        self.running = False
        if self.midi_event is not None:
            self.midi_event.cancel()
            self.midi_event = None
        if self.visual_event is not None:
            self.visual_event.cancel()
            self.visual_event = None
        self.all_sound_off()
        if close_midi:
            try:
                synth = globals().get("synthesizer")
                if synth is not None:
                    synth.close()
                globals()["synthesizer"] = None
            except Exception:
                Logger.exception("PythonHere: Could not close synthesizer")
        pythonhere_intro_state.update(
            ok=True,
            stage="stopped",
            message="PythonHere intro stopped",
            error=None,
        )

    def _loop_position(self):
        elapsed_since_tick = max(0.0, time.perf_counter() - self.last_tick_time)
        micro = min(0.999, elapsed_since_tick / TICK_SECONDS)

        step_float = self.last_played_step + micro
        step_float %= STEPS_PER_LOOP

        step = int(step_float)
        phase = step_float / STEPS_PER_LOOP

        return step, micro, phase

    def midi_tick(self, dt):
        global midistream_active_notes, midistream_used_channels
        if not self.running:
            return False

        played_step = self.step

        if self.midi_enabled:
            try:
                expired = []
                survivors = []
                for channel, note, remaining_steps in self.active_notes:
                    remaining_steps -= 1
                    if remaining_steps <= 0:
                        expired.append((channel, note))
                    else:
                        survivors.append((channel, note, remaining_steps))
                self.active_notes = survivors

                if expired:
                    self._safe_note_off_list(expired)

                events = self.note_pattern[played_step]
                if events:
                    cmd = []
                    for channel, note, velocity, _duration in events:
                        cmd += midi_note_on(note, channel=channel, velocity=velocity)
                    get_synthesizer().write(cmd)

                    for channel, note, _velocity, duration in events:
                        self.active_notes.append((channel, note, duration))
                        midistream_active_notes.add((channel, note))
                        midistream_used_channels.add(channel)
                        self.used_channels.add(channel)

            except MIDIException as exc:
                self.midi_enabled = False
                self.midi_error = f"{type(exc).__name__}: {exc}"
                pythonhere_intro_state.update(
                    ok=False,
                    stage="midi_tick",
                    message="MIDI disabled after sequencer error",
                    error=self.midi_error,
                )
                self.ui.ids.status_label.text = "MIDI error / visual sequencer still loops"
                Logger.exception("PythonHere: MIDI intro tick failed")
            except Exception as exc:
                self.midi_enabled = False
                self.midi_error = f"{type(exc).__name__}: {exc}"
                pythonhere_intro_state.update(
                    ok=False,
                    stage="midi_tick",
                    message="MIDI disabled after sequencer error",
                    error=self.midi_error,
                )
                self.ui.ids.status_label.text = "MIDI error / visual sequencer still loops"
                Logger.exception("PythonHere: MIDI intro tick failed")

        self.last_played_step = played_step
        self.last_tick_time = time.perf_counter()
        self.step = (self.step + 1) % STEPS_PER_LOOP
        pythonhere_intro_state["step"] = played_step
        pythonhere_intro_state["next_step"] = self.step
        return True

    def visual_tick(self, dt):
        if not self.running:
            return False

        visual_step, micro, phase = self._loop_position()

        feature_index = min(
            len(self.cards) - 1,
            visual_step // FEATURE_STEPS,
        )

        local_feature_phase = (
            (visual_step % FEATURE_STEPS) + micro
        ) / FEATURE_STEPS

        active16 = visual_step % BLOCK_STEPS

        version_char = ["0", "1", "2", "x"][
            (visual_step % BLOCK_STEPS) // 4
        ]
        version_text = "0.2." + version_char

        block = visual_step // BLOCK_STEPS
        local = visual_step % BLOCK_STEPS

        if feature_index != self.current_feature_index:
            card = self.cards[feature_index]
            self.ui.ids.feature_title.text = card["title"]
            self.ui.ids.feature_subtitle.text = card["subtitle"]
            title_len = len(card["title"])
            self.ui.ids.feature_title.font_size = sp(18 if title_len > 28 else 22)
            self.current_feature_index = feature_index

        if version_text != self.current_version_text:
            self.ui.ids.version_label.text = version_text
            self.current_version_text = version_text

        fade_in = min(1.0, local_feature_phase * 5.5)
        fade_out = min(1.0, (1.0 - local_feature_phase) * 5.5)
        panel_alpha = max(0.0, min(fade_in, fade_out))
        pulse = 0.5 + 0.5 * math.sin(2.0 * math.pi * ((active16 + micro) / BLOCK_STEPS))

        self.ui.ids.feature_panel.opacity = 0.62 + 0.38 * panel_alpha
        self.ui.ids.python_label.opacity = 0.82 + 0.18 * pulse
        self.ui.ids.here_label.opacity = 0.82 + 0.18 * (1.0 - pulse)
        self.ui.ids.version_label.color = (
            0.72 + 0.20 * pulse,
            1.0,
            1.0,
            1.0,
        )

        for i, btn in enumerate(self.beat_buttons):
            distance = min((i - active16) % BLOCK_STEPS, (active16 - i) % BLOCK_STEPS)
            intensity = max(0.0, 1.0 - distance / 5.0)
            if i == active16:
                btn.background_color = (0.05, 0.95, 1.0, 0.98)
                btn.color = (0.0, 0.02, 0.04, 1.0)
            elif i % 4 == 0:
                btn.background_color = (0.18, 0.34 + 0.24 * intensity, 0.62, 0.78)
                btn.color = (0.78, 1.0, 1.0, 0.82)
            else:
                btn.background_color = (0.02, 0.11 + 0.22 * intensity, 0.16 + 0.28 * intensity, 0.70)
                btn.color = (0.42, 0.95, 1.0, 0.74)

        for i, meter in enumerate(self.meters):
            meter.value = 8 + 86 * abs(math.sin(2.0 * math.pi * (phase * (i + 1) + i * 0.091)))

        self.ui.ids.osc_a.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 4.0))
        self.ui.ids.osc_b.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 8.0 + 0.18))
        self.ui.ids.osc_c.value = 50 + 48 * math.sin(2.0 * math.pi * (phase * 16.0 + 0.37))

        w = max(1.0, float(self.ui.width))
        h = max(1.0, float(self.ui.height))

        self.ui.ids.ticker.x = self.ui.width - phase * (self.ui.width + self.ui._ticker_width)

        scan_y = ((visual_step + micro) / STEPS_PER_LOOP) * h
        self.scan_rect.pos = (0, scan_y)
        self.scan_rect.size = (w, dp(3))
        self.scan_color.rgba = (0.0, 1.0, 0.72, 0.06 + 0.08 * pulse)

        wave_points = []
        base_y = h * 0.455
        amp = h * 0.025
        for i in range(32):
            x = w * i / 31.0
            y = base_y + amp * math.sin(2.0 * math.pi * (phase * 8.0 + i / 7.0))
            wave_points.extend([x, y])
        self.wave_line.points = wave_points
        self.wave_color.rgba = (0.0, 0.82 + 0.18 * pulse, 1.0, 0.34 + 0.22 * pulse)

        for color, dot, i in self.particle_items:
            px = (w * ((i * 37) % 101) / 100.0 + phase * w * (0.25 + (i % 5) * 0.06)) % w
            py = h * (0.20 + 0.62 * (((i * 19) % 97) / 96.0))
            py += math.sin(2.0 * math.pi * (phase * (1 + (i % 4)) + i * 0.13)) * h * 0.018
            size = dp(1.6 + (i % 4))
            dot.pos = (px, py)
            dot.size = (size, size)
            color.rgba = (
                0.18 + 0.12 * (i % 3),
                0.78 + 0.22 * pulse,
                1.0,
                0.18 + 0.22 * ((i + active16) % 4 == 0),
            )

        self.ui.ids.status_label.text = (
            f"STEP {visual_step:02d}/63  BLOCK {block + 1}/4  LOCAL {local:02d}  "
            f"FEATURE {feature_index + 1}/8"
        )

        pythonhere_intro_state["visual_step"] = visual_step
        pythonhere_intro_state["phase"] = phase
        pythonhere_intro_state["feature_index"] = feature_index
        pythonhere_intro_state["version"] = version_text

        return True


def stop_pythonhere_intro(close_midi=False):
    controller = globals().get("pythonhere_intro_controller")
    if controller is not None:
        controller.stop(close_midi=close_midi)


try:
    pythonhere_intro_ui = Builder.load_string(KV)
    if pythonhere_intro_ui is None:
        raise RuntimeError("Builder.load_string(KV) returned None")

    pythonhere_intro_controller = PythonHereIntroController(
        pythonhere_intro_ui,
        pythonhere_intro_ticker_text,
        announcements,
    )

    root.clear_widgets()
    root.add_widget(pythonhere_intro_ui)

    pythonhere_intro_controller.start()


except Exception as exc:
    pythonhere_last_error = f"{type(exc).__name__}: {exc}"
    pythonhere_intro_state.update(
        ok=False,
        stage="setup_failed",
        message="Could not start PythonHere 0.2.x intro",
        error=pythonhere_last_error,
    )
    Logger.exception("PythonHere: Could not start PythonHere 0.2.x intro")
    Popup(
        title="PythonHere intro error",
        content=Label(
            text=pythonhere_last_error,
            text_size=(dp(280), None),
            halign="center",
            valign="middle",
        ),
        size_hint=(0.86, 0.34),
    ).open()