""" Tutori mock engine — runs anywhere, no GPU, no model downloads. Used automatically when the app is not running on a Space (local dev), so the whole UI — whiteboard animation, audio-synced playback, chat flow — can be exercised end to end. Speaks with soft synthesized tones instead of TTS. """ import base64 import io import json import os import time import numpy as np import soundfile as sf from board_quality import diagram_family, improve_step_board # Replay mode: stream captured real-lesson event traces with original (or # capped) timing — used to record authentic demo footage locally. Accepts a # comma-separated list; the Nth turn replays the Nth trace. REPLAY = os.environ.get("TUTORI_REPLAY") REPLAY_WAITCAP = float(os.environ.get("TUTORI_REPLAY_WAITCAP", "1.4")) _replay_turn = {"i": 0} SR = 24000 def _tone_audio(seconds): """A quiet, pleasant placeholder 'voice' so playback timing is real.""" t = np.linspace(0, seconds, int(SR * seconds), endpoint=False) f = 196 + 30 * np.sin(2 * np.pi * 0.7 * t) wave = 0.06 * np.sin(2 * np.pi * f * t) * (0.55 + 0.45 * np.sin(2 * np.pi * 2.1 * t)) env = np.minimum(1, np.minimum(t / 0.15, (seconds - t) / 0.25).clip(0)) buf = io.BytesIO() sf.write(buf, (wave * env).astype(np.float32), SR, format="WAV", subtype="PCM_16") return base64.b64encode(buf.getvalue()).decode("ascii") def _step(say, board): dur = max(2.4, len(say.split()) * 0.34) return {"say": say, "board": board, "audio": _tone_audio(dur), "dur": dur} def _templated_lesson(topic): if diagram_family(topic) != "pythagorean": return None says = [ "Let's identify the right triangle first: the two legs make the L shape, and c is the hypotenuse across from it.", "The theorem says the square on c equals the two leg squares added together.", "For a concrete example, set a to three and b to four, then substitute those values into the formula.", "That gives twenty five, so c is five. Notice five is the longest side, which matches the drawing.", ] out = [] for i, say in enumerate(says): board = improve_step_board(topic, i, say, []) if i == 0: board = [{"op": "clear"}] + board out.append(_step(say, board)) return out def _demo_lesson(topic): t = topic.strip().rstrip("?!.") or "how rainbows form" templated = _templated_lesson(t) if templated: return templated return [ _step(f"Great question! Let's break down {t} together, step by step.", [{"op": "clear"}, {"op": "title", "text": t.title()[:42]}]), _step("First, picture the big idea as three connected parts.", [{"op": "box", "at": [8, 22], "w": 24, "h": 11, "label": "Input", "color": "blue"}, {"op": "box", "at": [38, 22], "w": 24, "h": 11, "label": "Process", "color": "purple"}, {"op": "box", "at": [68, 22], "w": 24, "h": 11, "label": "Result", "color": "green"}]), _step("Each part feeds the next one, like a little assembly line.", [{"op": "arrow", "from": [32, 27.5], "to": [38, 27.5], "color": "ink"}, {"op": "arrow", "from": [62, 27.5], "to": [68, 27.5], "color": "ink"}]), _step("And here's how the effect grows over time — slowly at first, then quickly.", [{"op": "axes", "at": [14, 40], "w": 44, "h": 26, "xlabel": "time", "ylabel": "effect"}, {"op": "curve", "points": [[15, 64], [26, 62], [36, 56], [46, 46], [55, 42]], "color": "red"}, {"op": "dot", "at": [46, 46], "color": "orange", "label": "tipping point"}]), _step("Quick check: which of the three parts would you like to zoom into first?", [{"op": "highlight", "at": [36, 33], "w": 28}, {"op": "text", "text": "your pick? →", "at": [66, 52], "size": "m", "color": "orange"}]), ] def run_turn(audio_path, typed_text, board_snapshot, history, profile, notes, board_now, pace, web_on, voice_on): if REPLAY: files = REPLAY.split(",") path = files[min(_replay_turn["i"], len(files) - 1)] _replay_turn["i"] += 1 trace = json.load(open(path)) t_prev = 0.0 for entry in trace["events"]: dt = max(0.0, entry["t"] - t_prev) t_prev = entry["t"] # compress thinking AND generation gaps — the browser queues steps # and paces playback by the audio itself dt = min(dt, REPLAY_WAITCAP) if dt: time.sleep(dt) yield entry["event"] return question = (typed_text or "").strip() if audio_path: yield {"type": "status", "status": "thinking", "detail": "Listening… (mock)"} time.sleep(0.5) question = question or "How do rainbows form?" yield {"type": "transcript", "text": question} if not question: question = "Tell me about what's on the board" if board_snapshot else "Teach me something fun" if web_on and not notes: yield {"type": "status", "status": "searching", "detail": f"Researching: {question[:60]} (mock)"} time.sleep(0.7) yield {"type": "research", "notes": f"- (mock) background notes about {question[:50]}"} yield {"type": "status", "status": "teaching", "detail": "Preparing your whiteboard lesson… (mock)"} time.sleep(0.6) says = [] for step in _demo_lesson(question): if not voice_on: step = dict(step, audio=None) says.append(step["say"]) yield {"type": "step", "step": step} time.sleep(0.4) yield {"type": "memory", "profile": { **(profile or {}), "last_topic": question[:80], "pace_notes": f"pace slider at {pace}", }} yield {"type": "memory", "profile": {"level": "curious beginner", "last_topic": "mock lesson"}} yield {"type": "coach", "suggestions": ["What happens if we go deeper?", "How does this connect to music?", "Could this work underwater?"]} yield {"type": "final", "text": " ".join(says), "error": None, "question": question, "elapsed": 3.0} MODELS_INFO = { "llm": "mock", "tts": "mock", "asr": "mock", "total_params": "0 (mock mode — run on a Space for the real models)", "mode": "mock", }