refactor(sphere): extract pure algorithm + add browser preview

Split MorphingSphere into a platform-agnostic algorithm core
(MorphingSphereCore.kt — no Android, no Compose, just kotlin.math) and a
Compose renderer that calls it. Swap Android-specific `Paint` + `Typeface` +
`nativeCanvas.drawText` for Compose's `TextMeasurer` + `drawText` so the
composable no longer depends on `android.graphics.*`.

Add `preview/web/` — a zero-dependency HTML+JS port of the same algorithm
that animates live in the browser. No Android Studio or emulator required;
serve with `python3 -m http.server --directory preview/web`.

The JS port mirrors MorphingSphereCore.kt line-for-line, including
`Math.imul`-based 32-bit hash math to match Kotlin's `Int` overflow and a
floored-positive modulo to match `.mod(n)`. Font rendering differs slightly
(OS default mono vs Android's FontFamily.Monospace) — bundle JetBrains Mono
later if pixel parity across surfaces is needed.

Sets up the same core for future Compose Desktop hot-reload and a terminal
TUI port for Hermes CLI.
This commit is contained in:
Claude
2026-04-19 02:22:00 +00:00
parent f5b726a33d
commit 9b3b41e715
5 changed files with 1194 additions and 449 deletions
@@ -13,150 +13,33 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import android.graphics.Paint
import android.graphics.Typeface
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
*
* Inspired by Amp Code's Supernova orb. Renders a sphere from monospace
* characters with layered procedural effects driven by [SphereState]:
* characters with layered procedural effects. Algorithm lives in
* [forEachSphereCell] (see `MorphingSphereCore.kt`) so the same math powers
* Android here, the JS browser preview in `preview/web/`, and any future
* renderer (Compose Desktop, terminal TUI).
*
* - **Hybrid brightness**: concentric distance-based zones + orbiting directional
* light that shifts the highlight across the surface ("the eye")
* - **Dual noise**: structural FBM (slow undulation) + turbulence (fast shimmer)
* - **Breathing radius**: slow expand/contract
* - **Core heartbeat**: brightness throb near center
* - **Radial flow**: outward energy drift
* - **Ripple waves**: concentric brightness rings radiating outward
* - **State-driven colors**: palette shifts per agent state
*
* Pure Compose Canvas — no OpenGL, no external libraries.
* This file is the Android/Compose renderer only — it owns animation state
* (`animateFloatAsState`, `rememberInfiniteTransition`) and text drawing.
*/
/** Agent visual state — controls animation parameters and color palette. */
enum class SphereState {
/** Calm breathing, slow wandering eye, gentle ripples. Present, waiting. */
Idle,
/** Faster pulse, tighter core, rapid eye scanning. Processing. */
Thinking,
/** Energy radiates outward, strong ripples, focused eye. Speaking. */
Streaming,
/** Voice mode — listening to user. Cool palette, subtle amplitude-driven motion. */
Listening,
/** Voice mode — speaking to user. Warm core, dramatic amplitude-driven motion. */
Speaking,
/** Red shift, erratic motion. Something wrong. */
Error
}
// ── State parameter system ───────────────────────────────────────────
private data class SphereParams(
val breatheSpeed: Float,
val breatheAmp: Float,
val lightSpeedX: Float,
val lightSpeedY: Float,
val lightInfluence: Float,
val coreTightness: Float,
val turbulenceAmp: Float,
val rippleScale: Float,
val heartbeatSpeed: Float,
val radialFlowSpeed: Float
)
private data class SphereColors(
val r1: Float, val g1: Float, val b1: Float, // color pole 1
val r2: Float, val g2: Float, val b2: Float // color pole 2
)
private fun paramsFor(state: SphereState) = when (state) {
SphereState.Idle -> SphereParams(
breatheSpeed = 0.5f, breatheAmp = 0.04f,
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
coreTightness = 0.75f, turbulenceAmp = 0.06f,
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
)
SphereState.Thinking -> SphereParams(
breatheSpeed = 0.8f, breatheAmp = 0.02f,
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
coreTightness = 0.90f, turbulenceAmp = 0.12f,
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
)
SphereState.Streaming -> SphereParams(
breatheSpeed = 0.3f, breatheAmp = 0.06f,
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
coreTightness = 0.60f, turbulenceAmp = 0.08f,
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
)
SphereState.Listening -> SphereParams(
// Calm base — voiceAmplitude modulates on top (see render loop).
breatheSpeed = 0.55f, breatheAmp = 0.035f,
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
coreTightness = 0.78f, turbulenceAmp = 0.05f,
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
)
SphereState.Speaking -> SphereParams(
// Assertive base — amplitude pushes it dramatically further.
breatheSpeed = 0.45f, breatheAmp = 0.05f,
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
coreTightness = 0.55f, turbulenceAmp = 0.07f,
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
)
SphereState.Error -> SphereParams(
breatheSpeed = 1.2f, breatheAmp = 0.03f,
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
coreTightness = 0.80f, turbulenceAmp = 0.15f,
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
)
}
private fun colorsFor(state: SphereState) = when (state) {
SphereState.Idle -> SphereColors(
0.25f, 0.85f, 0.40f, // green
0.61f, 0.42f, 0.94f // purple
)
SphereState.Thinking -> SphereColors(
0.30f, 0.55f, 0.95f, // blue
0.55f, 0.35f, 0.90f // purple
)
SphereState.Streaming -> SphereColors(
0.20f, 0.90f, 0.50f, // green
0.25f, 0.80f, 0.85f // teal
)
SphereState.Listening -> SphereColors(
// Cool soft blue/purple — cooler than Idle's green/purple.
0.35f, 0.55f, 0.95f, // #597EF2 soft blue
0.65f, 0.45f, 0.95f // #A573F2 soft purple
)
SphereState.Speaking -> SphereColors(
// Vibrant green/teal — same family as Streaming but punchier.
// The render loop pushes core toward white as amplitude peaks.
0.25f, 0.92f, 0.55f, // #40EB8C vivid green
0.30f, 0.85f, 0.88f // #4DD9E0 teal
)
SphereState.Error -> SphereColors(
0.90f, 0.30f, 0.25f, // red
0.85f, 0.50f, 0.20f // orange
)
}
// ── Main composable ──────────────────────────────────────────────────
@Composable
fun MorphingSphere(
modifier: Modifier = Modifier,
@@ -168,66 +51,28 @@ fun MorphingSphere(
fixedTime: Float? = null,
fixedColorPhase: Float? = null
) {
// Clamp amplitude once — downstream math assumes 0..1.
val amp = voiceAmplitude.coerceIn(0f, 1f)
// ── Animated state parameters (smooth 800ms transitions) ─────
val targetP = remember(state) { paramsFor(state) }
val targetC = remember(state) { colorsFor(state) }
val spec = tween<Float>(800, easing = FastOutSlowInEasing)
// ── voiceMode expansion scalar ───────────────────────────────
// 1.0 = normal, ~1.08 = expanded (bounded to avoid data-ring overflow).
val voiceRadiusScale by animateFloatAsState(
targetValue = if (voiceMode) 1.08f else 1.0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "voiceExpand"
)
val baseBreatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
val breatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
val breatheAmp by animateFloatAsState(targetP.breatheAmp, spec, label = "bAmp")
val lightSpeedX by animateFloatAsState(targetP.lightSpeedX, spec, label = "lsX")
val lightSpeedY by animateFloatAsState(targetP.lightSpeedY, spec, label = "lsY")
val lightInfluence by animateFloatAsState(targetP.lightInfluence, spec, label = "lInf")
val coreTightness by animateFloatAsState(targetP.coreTightness, spec, label = "core")
val baseTurbulence by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
val turbulenceAmp by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
val rippleScale by animateFloatAsState(targetP.rippleScale, spec, label = "rip")
val heartbeatSpeed by animateFloatAsState(targetP.heartbeatSpeed, spec, label = "hb")
val radialFlowSpeed by animateFloatAsState(targetP.radialFlowSpeed, spec, label = "rf")
// ── Voice amplitude modulation ────────────────────────────────
// Listening = subtle (≤30% boost); Speaking = dramatic (up to 3×).
// Idle/Thinking/Streaming/Error ignore amplitude — existing behavior preserved.
val breatheSpeed = when (state) {
SphereState.Listening -> lerp(baseBreatheSpeed, baseBreatheSpeed * 1.3f, amp * 0.5f)
SphereState.Speaking -> lerp(baseBreatheSpeed, baseBreatheSpeed * 2.0f, amp)
else -> baseBreatheSpeed
}
val turbulenceAmp = when (state) {
SphereState.Listening -> baseTurbulence + amp * 0.15f
SphereState.Speaking -> baseTurbulence + amp * 0.5f
else -> baseTurbulence
}
// Core warmth: 0.30 is the existing constant baked into the render loop's
// warmth term (see line where `warmth = (1f - normDist^2) * 0.12f` is mixed).
// Speaking pushes this multiplier from 0.3 → 1.0 as amplitude rises, driving
// the core bright→white. Listening holds at 0.3 (no change vs. other states).
val coreWarmth = when (state) {
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
else -> 0.30f
}
// Perimeter wobble — existing code uses a fixed 0.06 multiplier.
val wobbleAmplitude = when (state) {
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
else -> 0.06f
}
// Data ring orbit speed — existing code uses `t * 0.4f`.
val dataRingSpeed = when (state) {
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
else -> 0.4f
}
val cr1 by animateFloatAsState(targetC.r1, spec, label = "cr1")
val cg1 by animateFloatAsState(targetC.g1, spec, label = "cg1")
val cb1 by animateFloatAsState(targetC.b1, spec, label = "cb1")
@@ -235,7 +80,6 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// ── Continuous time animations ──────────────────────────────
val transition = rememberInfiniteTransition(label = "sphere")
val animatedTime by transition.animateFloat(
initialValue = 0f,
@@ -259,25 +103,11 @@ fun MorphingSphere(
val time = fixedTime ?: animatedTime
val colorPhase = fixedColorPhase ?: animatedColorPhase
// Multiple character sets that rotate over time for surface "activity"
val charSets = arrayOf(
" ·:;=+*#%@", // technical dots
" .:;=+*#%@", // classic with semicolons
" ·;:+=*%#@", // shuffled mid-range
" .:;+=*#@%" // variant ordering
)
// Data ring characters (orbit the sphere like processing data)
val dataChars = "01<>[]{}|/\\~^"
val cols = 58
val rows = 34
val paint = remember {
Paint().apply {
typeface = Typeface.MONOSPACE
isAntiAlias = true
}
}
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
val canvasW = size.width
@@ -285,277 +115,43 @@ fun MorphingSphere(
val cellW = canvasW / cols
val cellH = canvasH / rows
val charSize = (cellW * 1.3f).coerceAtMost(cellH * 1.1f)
paint.textSize = charSize
val cx = cols / 2f
val cy = rows / 2f
val charAspect = cellW / cellH
// Reduced from 0.72 so data ring (1.55x) fits within grid.
// voiceRadiusScale is ~1.08 in voiceMode, 1.0 otherwise — bounded so the
// data ring outer edge (1.55x) still stays within the drawable region.
val maxRadiusFromRows = (rows / 2f) * 0.60f
val maxRadiusFromCols = (cols / 2f) * charAspect * 0.60f
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * voiceRadiusScale
val t = time
val style = TextStyle(
fontSize = charSize.toSp(),
fontFamily = FontFamily.Monospace
)
// ── Breathing ────────────────────────────────────────────
val breathe = sin(t * breatheSpeed) * breatheAmp
val breathingRadius = baseRadius * (1f + breathe)
val frame = SphereFrame(
cols = cols, rows = rows, charAspect = charAspect,
state = state, time = time, colorPhase = colorPhase,
breatheSpeed = breatheSpeed, breatheAmp = breatheAmp,
lightSpeedX = lightSpeedX, lightSpeedY = lightSpeedY,
lightInfluence = lightInfluence, coreTightness = coreTightness,
turbulenceAmp = turbulenceAmp, rippleScale = rippleScale,
heartbeatSpeed = heartbeatSpeed, radialFlowSpeed = radialFlowSpeed,
cr1 = cr1, cg1 = cg1, cb1 = cb1,
cr2 = cr2, cg2 = cg2, cb2 = cb2,
intensity = intensity, toolCallBurst = toolCallBurst,
voiceAmplitude = amp, voiceMode = voiceMode,
voiceRadiusScale = voiceRadiusScale
)
// ── Orbiting directional light ("the eye") ──────────────
// Lissajous orbit (different X/Y speeds) + noise jitter
// for organic, non-repeating path. lx/ly at ±0.65 creates
// strong enough asymmetry that the highlight visibly shifts.
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
val lightAngle1 = t * lightSpeedX + noiseJitter1
val lightAngle2 = t * lightSpeedY + noiseJitter2
val lx = sin(lightAngle1) * 0.65f
val ly = cos(lightAngle2) * 0.65f
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
// ── Core heartbeat ──────────────────────────────────────
val heartbeat = sin(t * heartbeatSpeed) * 0.5f + 0.5f
// ── Color palette (animated poles + phase oscillation) ───
val pulse = sin(colorPhase) * 0.5f + 0.5f
val colR = lerp(cr1, cr2, pulse)
val colG = lerp(cg1, cg2, pulse)
val colB = lerp(cb1, cb2, pulse)
val distWeight = 1f - lightInfluence
// Intensity/tool call modulation of state params
val effTurbulence = turbulenceAmp + intensity * 0.04f + toolCallBurst * 0.15f
val effRadialFlow = radialFlowSpeed + intensity * 0.3f
val effRipple = rippleScale + intensity * 0.5f + toolCallBurst * 1.0f
for (row in 0 until rows) {
for (col in 0 until cols) {
val dx = (col - cx) * charAspect
val dy = (row - cy)
val dist = sqrt(dx * dx + dy * dy)
val angle = atan2(dy, dx)
// ── Perimeter (subtle 6% wobble — amplified by voice) ───
val perimeterNoise = fbm(
angle * 1.8f + t * 0.08f,
angle * 0.7f + t * 0.12f
) * 2f - 1f
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
val glowRadius = distortedRadius * 1.35f
val dataRingInner = distortedRadius * 1.40f
val dataRingOuter = distortedRadius * 1.55f
val normDist = dist / distortedRadius
if (dist > dataRingOuter) continue
val px = col * cellW
val py = row * cellH + cellH * 0.8f
if (normDist <= 1f) {
// ── INSIDE SPHERE ────────────────────────────
// Surface normal
val nx = dx / distortedRadius
val ny2 = dy / distortedRadius
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
val nz = sqrt(nzSq)
// Distance-based brightness (concentric zones)
val distBrightness = (1f - normDist * normDist * coreTightness)
.coerceAtLeast(0.15f)
// Directional light (shifts highlight across surface)
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
.coerceIn(0f, 1f)
// Structural noise (slow undulation)
val structural = fbm(
col * 0.25f + t * 0.18f,
row * 0.25f + t * 0.13f,
octaves = 2
) * 0.15f - 0.075f
// Turbulence (fast shimmer, boosted by intensity + tool calls)
val turbulence = fbm(
col * 0.8f + t * 0.6f,
row * 0.8f + t * 0.45f,
octaves = 2
) * effTurbulence - effTurbulence * 0.5f
// Radial flow (outward energy drift, faster when streaming)
val radialFlow = fbm(
angle * 2f + t * 0.15f,
dist * 0.3f - t * effRadialFlow,
octaves = 2
) * 0.06f - 0.03f
// Ripple waves (stronger during streaming/tool calls)
val ripple = (
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
) * effRipple
// Core heartbeat (subtle glow, concentrated at center)
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
// ── Hybrid brightness ────────────────────────
val brightness = distWeight * distBrightness +
lightInfluence * directionalLight +
heartbeatFx
val charNoise = structural + turbulence + radialFlow + ripple
// Character rotation: cycle through char sets over time
// Each cell picks a set based on position + time, creating
// surface "activity" where characters shift independently
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
val chars = charSets[rotationPhase.and(3)] // mod 4 via bitmask
val charIdx = ((brightness + charNoise) * (chars.length - 1))
.toInt().coerceIn(1, chars.length - 1)
val ch = chars[charIdx]
// Edge fade (quadratic, starts at 0.80)
val edgeFade = when {
normDist > 0.80f -> {
val ef = (normDist - 0.80f) / 0.20f
1f - ef * ef
}
else -> 1f
}
// Scanline: dimming on odd rows (CRT/holographic feel)
val scanline = if (row % 2 == 1) 0.82f else 1f
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
.coerceIn(0.1f, 1f)
// Core warmth (center bleeds towards white).
// coreWarmth is 0.30 for all non-voice states (→ 0.12 multiplier,
// the historical value) and scales up to 1.0 when Speaking peaks.
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
val lightBoost = directionalLight * 0.08f
paint.color = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255),
((colR + lightBoost + warmth) * 255).toInt().coerceIn(0, 255),
((colG + lightBoost * 0.5f + warmth) * 255).toInt().coerceIn(0, 255),
((colB + lightBoost + warmth) * 255).toInt().coerceIn(0, 255)
)
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
} else if (dist <= glowRadius) {
// ── GLOW / DEBRIS ZONE ───────────────────────
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
val sparsityNoise = fbm(
angle * 3.5f + t * 0.25f,
dist * 0.4f + t * 0.08f,
octaves = 2
)
val sparsityThreshold = 0.35f + glowT * 0.25f
if (sparsityNoise < sparsityThreshold) continue
val debrisChars = "·:;- "
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
.toInt().coerceIn(0, debrisChars.length - 1)
val ch = debrisChars[debrisIdx]
if (ch == ' ') continue
val alpha = glowFalloff * 0.85f
paint.color = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255),
(colR * 255).toInt().coerceIn(0, 255),
(colG * 255).toInt().coerceIn(0, 255),
(colB * 255).toInt().coerceIn(0, 255)
)
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
} else if (dist >= dataRingInner) {
// ── DATA RING ────────────────────────────────
// Sparse orbiting characters like processing data.
// Angle offset by time = characters appear to orbit.
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
// Orbiting: offset angle by time (different layers at different speeds).
// dataRingSpeed is 0.4 default, spun up to ~1.6 at Speaking peak.
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
// Sparsity: only render ~15% of ring positions
val ringNoise = fbm(
orbitAngle * 4f + t * 0.3f,
ringT * 3f + t * 0.15f,
octaves = 2
)
if (ringNoise < 0.55f) continue
// Pick character from data set, cycling with orbit
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
.toInt().mod(dataChars.length)
val ch = dataChars[dataIdx]
// Fade: bright at inner edge, fading outward
val ringFade = (1f - ringT).coerceIn(0f, 1f)
val alpha = ringFade * 0.65f
paint.color = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255),
(colR * 0.85f * 255).toInt().coerceIn(0, 255),
(colG * 0.85f * 255).toInt().coerceIn(0, 255),
(colB * 0.85f * 255).toInt().coerceIn(0, 255)
)
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
}
}
forEachSphereCell(frame) { cell ->
val layout = textMeasurer.measure(cell.char.toString(), style)
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
// Compose `drawText` uses top-left — offset by firstBaseline to match.
val px = cell.col * cellW
val py = cell.row * cellH + cellH * 0.8f - layout.firstBaseline
drawText(
textLayoutResult = layout,
color = Color(cell.r, cell.g, cell.b, cell.alpha),
topLeft = Offset(px, py)
)
}
}
}
// ── Procedural noise ─────────────────────────────────────────────────
private fun hash(x: Int, y: Int): Float {
var h = x * 374761393 + y * 668265263
h = (h xor (h ushr 13)) * 1274126177
h = h xor (h ushr 16)
return (h and 0x7fffffff) / 2147483647f
}
private fun smoothNoise(x: Float, y: Float): Float {
val xi = floor(x).toInt()
val yi = floor(y).toInt()
val xf = x - xi
val yf = y - yi
val u = xf * xf * (3f - 2f * xf)
val v = yf * yf * (3f - 2f * yf)
val n00 = hash(xi, yi)
val n10 = hash(xi + 1, yi)
val n01 = hash(xi, yi + 1)
val n11 = hash(xi + 1, yi + 1)
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
}
private fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
var value = 0f
var amplitude = 0.5f
var frequency = 1f
for (i in 0 until octaves) {
value += amplitude * smoothNoise(x * frequency, y * frequency)
amplitude *= 0.5f
frequency *= 2f
}
return value
}
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
// ── Previews ─────────────────────────────────────────────────────────
@Preview(name = "Idle", showBackground = true, backgroundColor = 0xFF0D0D0D, widthDp = 360, heightDp = 640)
@@ -0,0 +1,412 @@
package com.hermesandroid.relay.ui.components
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Pure, platform-agnostic core of the ASCII morphing sphere.
*
* No Android, no Compose — only `kotlin.math`. Intended as the single source of
* truth for the sphere algorithm. Android renders via Compose Canvas; a JS port
* in `preview/web/` mirrors this file for browser iteration; future renderers
* (Compose Desktop, terminal TUI) can call this same core.
*/
/** Agent visual state — controls animation parameters and color palette. */
enum class SphereState {
Idle,
Thinking,
Streaming,
Listening,
Speaking,
Error
}
/** Animated parameter bundle — interpolated by the caller for smooth state transitions. */
data class SphereParams(
val breatheSpeed: Float,
val breatheAmp: Float,
val lightSpeedX: Float,
val lightSpeedY: Float,
val lightInfluence: Float,
val coreTightness: Float,
val turbulenceAmp: Float,
val rippleScale: Float,
val heartbeatSpeed: Float,
val radialFlowSpeed: Float
)
/** Two color poles mixed by a time-varying phase. */
data class SphereColors(
val r1: Float, val g1: Float, val b1: Float,
val r2: Float, val g2: Float, val b2: Float
)
fun paramsFor(state: SphereState): SphereParams = when (state) {
SphereState.Idle -> SphereParams(
breatheSpeed = 0.5f, breatheAmp = 0.04f,
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
coreTightness = 0.75f, turbulenceAmp = 0.06f,
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
)
SphereState.Thinking -> SphereParams(
breatheSpeed = 0.8f, breatheAmp = 0.02f,
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
coreTightness = 0.90f, turbulenceAmp = 0.12f,
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
)
SphereState.Streaming -> SphereParams(
breatheSpeed = 0.3f, breatheAmp = 0.06f,
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
coreTightness = 0.60f, turbulenceAmp = 0.08f,
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
)
SphereState.Listening -> SphereParams(
breatheSpeed = 0.55f, breatheAmp = 0.035f,
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
coreTightness = 0.78f, turbulenceAmp = 0.05f,
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
)
SphereState.Speaking -> SphereParams(
breatheSpeed = 0.45f, breatheAmp = 0.05f,
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
coreTightness = 0.55f, turbulenceAmp = 0.07f,
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
)
SphereState.Error -> SphereParams(
breatheSpeed = 1.2f, breatheAmp = 0.03f,
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
coreTightness = 0.80f, turbulenceAmp = 0.15f,
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
)
}
fun colorsFor(state: SphereState): SphereColors = when (state) {
SphereState.Idle -> SphereColors(
0.25f, 0.85f, 0.40f,
0.61f, 0.42f, 0.94f
)
SphereState.Thinking -> SphereColors(
0.30f, 0.55f, 0.95f,
0.55f, 0.35f, 0.90f
)
SphereState.Streaming -> SphereColors(
0.20f, 0.90f, 0.50f,
0.25f, 0.80f, 0.85f
)
SphereState.Listening -> SphereColors(
0.35f, 0.55f, 0.95f,
0.65f, 0.45f, 0.95f
)
SphereState.Speaking -> SphereColors(
0.25f, 0.92f, 0.55f,
0.30f, 0.85f, 0.88f
)
SphereState.Error -> SphereColors(
0.90f, 0.30f, 0.25f,
0.85f, 0.50f, 0.20f
)
}
/** All inputs needed to render one frame — populated by the caller from animation state. */
data class SphereFrame(
val cols: Int,
val rows: Int,
val charAspect: Float,
val state: SphereState,
val time: Float,
val colorPhase: Float,
// Animated base params (caller smooths transitions)
val breatheSpeed: Float,
val breatheAmp: Float,
val lightSpeedX: Float,
val lightSpeedY: Float,
val lightInfluence: Float,
val coreTightness: Float,
val turbulenceAmp: Float,
val rippleScale: Float,
val heartbeatSpeed: Float,
val radialFlowSpeed: Float,
// Animated colors
val cr1: Float, val cg1: Float, val cb1: Float,
val cr2: Float, val cg2: Float, val cb2: Float,
// Intensity + modulation
val intensity: Float,
val toolCallBurst: Float,
val voiceAmplitude: Float,
val voiceMode: Boolean,
val voiceRadiusScale: Float
)
/** What to draw at one grid cell. RGB + alpha in 0..1. */
data class SphereCell(
val col: Int,
val row: Int,
val char: Char,
val r: Float,
val g: Float,
val b: Float,
val alpha: Float
)
private val charSets = arrayOf(
" ·:;=+*#%@",
" .:;=+*#%@",
" ·;:+=*%#@",
" .:;+=*#@%"
)
private const val dataChars = "01<>[]{}|/\\~^"
/**
* Iterates the `rows × cols` grid for one frame and invokes `onCell` for every
* cell that should be drawn. Cells outside the drawable region (or excluded by
* sparsity) are silently skipped — the callback sees only visible glyphs.
*/
fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
val amp = frame.voiceAmplitude.coerceIn(0f, 1f)
// ── Voice modulation of animated base params ─────────────────
val breatheSpeed = when (frame.state) {
SphereState.Listening -> lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3f, amp * 0.5f)
SphereState.Speaking -> lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0f, amp)
else -> frame.breatheSpeed
}
val turbulenceAmp = when (frame.state) {
SphereState.Listening -> frame.turbulenceAmp + amp * 0.15f
SphereState.Speaking -> frame.turbulenceAmp + amp * 0.5f
else -> frame.turbulenceAmp
}
val coreWarmth = when (frame.state) {
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
else -> 0.30f
}
val wobbleAmplitude = when (frame.state) {
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
else -> 0.06f
}
val dataRingSpeed = when (frame.state) {
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
else -> 0.4f
}
val cx = frame.cols / 2f
val cy = frame.rows / 2f
val charAspect = frame.charAspect
// Matches legacy 0.60 envelope so the data ring (1.55×) fits the grid.
val maxRadiusFromRows = (frame.rows / 2f) * 0.60f
val maxRadiusFromCols = (frame.cols / 2f) * charAspect * 0.60f
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale
val t = frame.time
val breathe = sin(t * breatheSpeed) * frame.breatheAmp
val breathingRadius = baseRadius * (1f + breathe)
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
val lightAngle1 = t * frame.lightSpeedX + noiseJitter1
val lightAngle2 = t * frame.lightSpeedY + noiseJitter2
val lx = sin(lightAngle1) * 0.65f
val ly = cos(lightAngle2) * 0.65f
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
val heartbeat = sin(t * frame.heartbeatSpeed) * 0.5f + 0.5f
val pulse = sin(frame.colorPhase) * 0.5f + 0.5f
val colR = lerp(frame.cr1, frame.cr2, pulse)
val colG = lerp(frame.cg1, frame.cg2, pulse)
val colB = lerp(frame.cb1, frame.cb2, pulse)
val distWeight = 1f - frame.lightInfluence
val effTurbulence = turbulenceAmp + frame.intensity * 0.04f + frame.toolCallBurst * 0.15f
val effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3f
val effRipple = frame.rippleScale + frame.intensity * 0.5f + frame.toolCallBurst * 1.0f
for (row in 0 until frame.rows) {
for (col in 0 until frame.cols) {
val dx = (col - cx) * charAspect
val dy = (row - cy)
val dist = sqrt(dx * dx + dy * dy)
val angle = atan2(dy, dx)
val perimeterNoise = fbm(
angle * 1.8f + t * 0.08f,
angle * 0.7f + t * 0.12f
) * 2f - 1f
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
val glowRadius = distortedRadius * 1.35f
val dataRingInner = distortedRadius * 1.40f
val dataRingOuter = distortedRadius * 1.55f
val normDist = dist / distortedRadius
if (dist > dataRingOuter) continue
if (normDist <= 1f) {
// ── INSIDE SPHERE ────────────────────────────
val nx = dx / distortedRadius
val ny2 = dy / distortedRadius
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
val nz = sqrt(nzSq)
val distBrightness = (1f - normDist * normDist * frame.coreTightness)
.coerceAtLeast(0.15f)
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
.coerceIn(0f, 1f)
val structural = fbm(
col * 0.25f + t * 0.18f,
row * 0.25f + t * 0.13f,
octaves = 2
) * 0.15f - 0.075f
val turbulence = fbm(
col * 0.8f + t * 0.6f,
row * 0.8f + t * 0.45f,
octaves = 2
) * effTurbulence - effTurbulence * 0.5f
val radialFlow = fbm(
angle * 2f + t * 0.15f,
dist * 0.3f - t * effRadialFlow,
octaves = 2
) * 0.06f - 0.03f
val ripple = (
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
) * effRipple
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
val brightness = distWeight * distBrightness +
frame.lightInfluence * directionalLight +
heartbeatFx
val charNoise = structural + turbulence + radialFlow + ripple
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
val chars = charSets[rotationPhase.and(3)]
val charIdx = ((brightness + charNoise) * (chars.length - 1))
.toInt().coerceIn(1, chars.length - 1)
val ch = chars[charIdx]
val edgeFade = when {
normDist > 0.80f -> {
val ef = (normDist - 0.80f) / 0.20f
1f - ef * ef
}
else -> 1f
}
val scanline = if (row % 2 == 1) 0.82f else 1f
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
.coerceIn(0.1f, 1f)
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
val lightBoost = directionalLight * 0.08f
onCell(SphereCell(
col = col, row = row, char = ch,
r = (colR + lightBoost + warmth).coerceIn(0f, 1f),
g = (colG + lightBoost * 0.5f + warmth).coerceIn(0f, 1f),
b = (colB + lightBoost + warmth).coerceIn(0f, 1f),
alpha = alpha
))
} else if (dist <= glowRadius) {
// ── GLOW / DEBRIS ZONE ───────────────────────
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
val sparsityNoise = fbm(
angle * 3.5f + t * 0.25f,
dist * 0.4f + t * 0.08f,
octaves = 2
)
val sparsityThreshold = 0.35f + glowT * 0.25f
if (sparsityNoise < sparsityThreshold) continue
val debrisChars = "·:;- "
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
.toInt().coerceIn(0, debrisChars.length - 1)
val ch = debrisChars[debrisIdx]
if (ch == ' ') continue
val alpha = glowFalloff * 0.85f
onCell(SphereCell(
col = col, row = row, char = ch,
r = colR.coerceIn(0f, 1f),
g = colG.coerceIn(0f, 1f),
b = colB.coerceIn(0f, 1f),
alpha = alpha
))
} else if (dist >= dataRingInner) {
// ── DATA RING ────────────────────────────────
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
val ringNoise = fbm(
orbitAngle * 4f + t * 0.3f,
ringT * 3f + t * 0.15f,
octaves = 2
)
if (ringNoise < 0.55f) continue
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
.toInt().mod(dataChars.length)
val ch = dataChars[dataIdx]
val ringFade = (1f - ringT).coerceIn(0f, 1f)
val alpha = ringFade * 0.65f
onCell(SphereCell(
col = col, row = row, char = ch,
r = (colR * 0.85f).coerceIn(0f, 1f),
g = (colG * 0.85f).coerceIn(0f, 1f),
b = (colB * 0.85f).coerceIn(0f, 1f),
alpha = alpha
))
}
}
}
}
// ── Procedural noise (public so renderers can share / verify behavior) ──
fun hash(x: Int, y: Int): Float {
var h = x * 374761393 + y * 668265263
h = (h xor (h ushr 13)) * 1274126177
h = h xor (h ushr 16)
return (h and 0x7fffffff) / 2147483647f
}
fun smoothNoise(x: Float, y: Float): Float {
val xi = floor(x).toInt()
val yi = floor(y).toInt()
val xf = x - xi
val yf = y - yi
val u = xf * xf * (3f - 2f * xf)
val v = yf * yf * (3f - 2f * yf)
val n00 = hash(xi, yi)
val n10 = hash(xi + 1, yi)
val n01 = hash(xi, yi + 1)
val n11 = hash(xi + 1, yi + 1)
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
}
fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
var value = 0f
var amplitude = 0.5f
var frequency = 1f
for (i in 0 until octaves) {
value += amplitude * smoothNoise(x * frequency, y * frequency)
amplitude *= 0.5f
frequency *= 2f
}
return value
}
fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
+49
View File
@@ -0,0 +1,49 @@
# MorphingSphere — browser preview
A zero-dependency browser harness for iterating on the ASCII morphing sphere
without Android Studio or an emulator.
## Run
```bash
# From repo root:
python3 -m http.server 8000 --directory preview/web
# then open http://localhost:8000/
```
Or open `preview/web/index.html` directly in a browser — the ES module import
requires `file://` access (works in Firefox, blocked in Chrome); the local
server path above works everywhere.
## Controls
- **State** — Idle · Thinking · Streaming · Listening · Speaking · Error
- **Voice mode** — expands the sphere radius ~8%
- **Voice amp** — 0..1, modulates Listening/Speaking behavior
- **Intensity** — general activity boost
- **Tool burst** — transient spike (ripples, turbulence)
- **Pause** — freezes animation; <kbd>Space</kbd> toggles
- **Reset t** — restart the time origin
- Keyboard: <kbd>1</kbd>..<kbd>6</kbd> picks a state
## How this relates to the Kotlin source
`sphere.js` is a **line-for-line mirror** of
`app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt`.
When you change the algorithm:
1. Edit `MorphingSphereCore.kt` (the source of truth)
2. Mirror the same change in `sphere.js`
3. Reload the browser to verify
This keeps Android + browser visually consistent. The only deliberate
differences are:
- **Font** — Android uses `FontFamily.Monospace`; browser uses the OS default
mono. Glyph widths and antialiasing differ slightly. Bundle JetBrains Mono
if you need pixel parity across surfaces.
- **32-bit integer math** — the hash function uses `Math.imul` + `|0` to
match Kotlin's `Int` overflow semantics exactly.
- **`.mod(n)`** — Kotlin's floored-positive modulo vs JS `%` sign-preserving;
the port handles this in the data ring code.
+420
View File
@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MorphingSphere — browser preview</title>
<style>
:root {
--bg: #0a0a0a;
--panel: rgba(20, 20, 20, 0.85);
--border: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.9);
--muted: rgba(255, 255, 255, 0.55);
--accent: #40eb8c;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
#stage {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
}
canvas#sphere {
background: var(--bg);
display: block;
}
#panel {
position: fixed;
top: 16px; right: 16px;
width: 280px;
padding: 14px 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-size: 13px;
user-select: none;
}
#panel h2 {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.row {
display: flex; align-items: center; justify-content: space-between;
margin: 8px 0;
gap: 10px;
}
.row label {
flex: 0 0 auto;
color: var(--muted);
font-size: 12px;
}
.row .value {
min-width: 36px;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--text);
}
input[type="range"] {
flex: 1;
accent-color: var(--accent);
}
select {
flex: 1;
background: #151515;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
}
#fps {
position: fixed;
bottom: 12px; left: 12px;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
#hint {
position: fixed;
bottom: 12px; right: 16px;
font-size: 11px;
color: var(--muted);
}
#hint code {
font-family: ui-monospace, Menlo, Consolas, monospace;
background: rgba(255,255,255,0.06);
padding: 1px 5px;
border-radius: 3px;
}
button.mini {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 7px;
font-size: 11px;
cursor: pointer;
}
button.mini:hover { color: var(--text); border-color: rgba(255,255,255,0.2); }
</style>
</head>
<body>
<div id="stage">
<canvas id="sphere"></canvas>
</div>
<div id="panel">
<h2>MorphingSphere</h2>
<div class="row">
<label>State</label>
<select id="state">
<option>Idle</option>
<option>Thinking</option>
<option>Streaming</option>
<option>Listening</option>
<option>Speaking</option>
<option>Error</option>
</select>
</div>
<div class="row">
<label>Voice mode</label>
<input type="checkbox" id="voiceMode">
</div>
<div class="row">
<label>Voice amp</label>
<input type="range" id="voiceAmp" min="0" max="1" step="0.01" value="0">
<span class="value" id="voiceAmpVal">0.00</span>
</div>
<div class="row">
<label>Intensity</label>
<input type="range" id="intensity" min="0" max="1" step="0.01" value="0">
<span class="value" id="intensityVal">0.00</span>
</div>
<div class="row">
<label>Tool burst</label>
<input type="range" id="toolBurst" min="0" max="1" step="0.01" value="0">
<span class="value" id="toolBurstVal">0.00</span>
</div>
<div class="row">
<label><input type="checkbox" id="paused"> Pause</label>
<button class="mini" id="resetTime">reset t</button>
</div>
<div class="row" style="opacity: 0.6;">
<label>Cols × Rows</label>
<span class="value" style="min-width: auto;">58 × 34</span>
</div>
</div>
<div id="fps">— fps</div>
<div id="hint"><code>space</code> pause · <code>1-6</code> state</div>
<script type="module">
import { SphereState, paramsFor, colorsFor, forEachSphereCell } from "./sphere.js";
// ── DOM wiring ─────────────────────────────────────────────────────
const canvas = document.getElementById("sphere");
const ctx = canvas.getContext("2d");
const stateSel = document.getElementById("state");
const voiceModeEl = document.getElementById("voiceMode");
const voiceAmpEl = document.getElementById("voiceAmp");
const voiceAmpVal = document.getElementById("voiceAmpVal");
const intensityEl = document.getElementById("intensity");
const intensityVal = document.getElementById("intensityVal");
const toolBurstEl = document.getElementById("toolBurst");
const toolBurstVal = document.getElementById("toolBurstVal");
const pausedEl = document.getElementById("paused");
const resetTimeBtn = document.getElementById("resetTime");
const fpsEl = document.getElementById("fps");
// Sync displayed values for sliders.
function bindSlider(el, display) {
const update = () => display.textContent = parseFloat(el.value).toFixed(2);
el.addEventListener("input", update);
update();
}
bindSlider(voiceAmpEl, voiceAmpVal);
bindSlider(intensityEl, intensityVal);
bindSlider(toolBurstEl, toolBurstVal);
// Keyboard shortcuts — space pauses, 1-6 pick state.
const stateKeys = ["Idle", "Thinking", "Streaming", "Listening", "Speaking", "Error"];
window.addEventListener("keydown", (e) => {
if (e.code === "Space") { pausedEl.checked = !pausedEl.checked; e.preventDefault(); }
const n = parseInt(e.key, 10);
if (n >= 1 && n <= 6) {
stateSel.value = stateKeys[n - 1];
stateSel.dispatchEvent(new Event("change"));
}
});
// ── Canvas sizing ──────────────────────────────────────────────────
const COLS = 58;
const ROWS = 34;
function resize() {
const dpr = window.devicePixelRatio || 1;
// Fit canvas into viewport while keeping 58:34 cell grid legible.
// Aspect chosen so cells are close to square for legible glyphs.
const vw = window.innerWidth;
const vh = window.innerHeight;
// Target grid aspect ~= COLS / (ROWS * 1.8) — chars are ~0.55 wide per em.
const targetAspect = COLS / (ROWS * 1.7);
let w = vw * 0.92;
let h = w / targetAspect;
if (h > vh * 0.92) {
h = vh * 0.92;
w = h * targetAspect;
}
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
window.addEventListener("resize", resize);
// ── Animated state params (mirrors Compose animateFloatAsState) ────
// FastOutSlowInEasing ≈ cubic-bezier(0.4, 0, 0.2, 1).
const EASE_DURATION = 0.8; // seconds
const VOICE_SCALE_DURATION = 0.6;
function fastOutSlowIn(t) {
// Good enough approximation of Android's FastOutSlowInEasing.
// Cubic-bezier(0.4, 0.0, 0.2, 1.0) — closed-form approximation:
if (t <= 0) return 0;
if (t >= 1) return 1;
// Solve x=t for bezier and evaluate y — cheap Newton's iteration.
const cx = 3 * 0.4, bx = 3 * (0.2 - 0.4) - cx, ax = 1 - cx - bx;
const cy = 3 * 0.0, by = 3 * (1.0 - 0.0) - cy, ay = 1 - cy - by;
// Newton solve for u given t = axu³ + bxu² + cxu
let u = t;
for (let i = 0; i < 6; i++) {
const x = ((ax * u + bx) * u + cx) * u;
const dx = (3 * ax * u + 2 * bx) * u + cx;
if (Math.abs(dx) < 1e-6) break;
u -= (x - t) / dx;
}
return ((ay * u + by) * u + cy) * u;
}
// Tween helper: returns interpolated value given start/target/startTime/duration.
class Tween {
constructor(value) {
this.current = value;
this.target = value;
this.start = value;
this.startTime = 0;
this.duration = EASE_DURATION;
}
setTarget(v, nowSec, duration = EASE_DURATION) {
if (v === this.target) return;
this.start = this.current;
this.target = v;
this.startTime = nowSec;
this.duration = duration;
}
update(nowSec) {
if (this.duration <= 0) { this.current = this.target; return; }
const t = Math.min(1, (nowSec - this.startTime) / this.duration);
this.current = this.start + (this.target - this.start) * fastOutSlowIn(t);
}
}
const initial = paramsFor(SphereState.Idle);
const initialC = colorsFor(SphereState.Idle);
const tw = {
breatheSpeed: new Tween(initial.breatheSpeed),
breatheAmp: new Tween(initial.breatheAmp),
lightSpeedX: new Tween(initial.lightSpeedX),
lightSpeedY: new Tween(initial.lightSpeedY),
lightInfluence: new Tween(initial.lightInfluence),
coreTightness: new Tween(initial.coreTightness),
turbulenceAmp: new Tween(initial.turbulenceAmp),
rippleScale: new Tween(initial.rippleScale),
heartbeatSpeed: new Tween(initial.heartbeatSpeed),
radialFlowSpeed: new Tween(initial.radialFlowSpeed),
cr1: new Tween(initialC.r1), cg1: new Tween(initialC.g1), cb1: new Tween(initialC.b1),
cr2: new Tween(initialC.r2), cg2: new Tween(initialC.g2), cb2: new Tween(initialC.b2),
voiceRadiusScale: new Tween(1.0),
};
function retargetFor(state, nowSec) {
const p = paramsFor(state);
const c = colorsFor(state);
tw.breatheSpeed.setTarget(p.breatheSpeed, nowSec);
tw.breatheAmp.setTarget(p.breatheAmp, nowSec);
tw.lightSpeedX.setTarget(p.lightSpeedX, nowSec);
tw.lightSpeedY.setTarget(p.lightSpeedY, nowSec);
tw.lightInfluence.setTarget(p.lightInfluence, nowSec);
tw.coreTightness.setTarget(p.coreTightness, nowSec);
tw.turbulenceAmp.setTarget(p.turbulenceAmp, nowSec);
tw.rippleScale.setTarget(p.rippleScale, nowSec);
tw.heartbeatSpeed.setTarget(p.heartbeatSpeed, nowSec);
tw.radialFlowSpeed.setTarget(p.radialFlowSpeed, nowSec);
tw.cr1.setTarget(c.r1, nowSec); tw.cg1.setTarget(c.g1, nowSec); tw.cb1.setTarget(c.b1, nowSec);
tw.cr2.setTarget(c.r2, nowSec); tw.cg2.setTarget(c.g2, nowSec); tw.cb2.setTarget(c.b2, nowSec);
}
stateSel.addEventListener("change", () => retargetFor(stateSel.value, perfNowSec()));
voiceModeEl.addEventListener("change", () => {
tw.voiceRadiusScale.setTarget(voiceModeEl.checked ? 1.08 : 1.0, perfNowSec(), VOICE_SCALE_DURATION);
});
resetTimeBtn.addEventListener("click", () => { timeOrigin = performance.now(); });
function perfNowSec() { return performance.now() / 1000; }
// ── Animation loop ─────────────────────────────────────────────────
let timeOrigin = performance.now();
let pausedAccum = 0; // total paused ms
let pauseStart = null;
let lastFrame = performance.now();
let fpsAccum = 0, fpsCount = 0;
pausedEl.addEventListener("change", () => {
if (pausedEl.checked) pauseStart = performance.now();
else if (pauseStart !== null) {
pausedAccum += performance.now() - pauseStart;
pauseStart = null;
}
});
function animationTime() {
const now = pausedEl.checked ? (pauseStart ?? performance.now()) : performance.now();
return (now - timeOrigin - pausedAccum) / 1000;
}
function colorPhaseAt(elapsedSec) {
const cycle = (elapsedSec * 1000) % 8000;
return (cycle / 8000) * 6.2832;
}
function render() {
const nowMs = performance.now();
const dt = nowMs - lastFrame;
lastFrame = nowMs;
fpsAccum += dt;
fpsCount++;
if (fpsAccum >= 500) {
const fps = Math.round(1000 * fpsCount / fpsAccum);
fpsEl.textContent = `${fps} fps`;
fpsAccum = 0; fpsCount = 0;
}
const nowSec = perfNowSec();
for (const k in tw) tw[k].update(nowSec);
// ── Canvas layout (mirrors Compose Canvas block) ──
const canvasW = parseFloat(canvas.style.width);
const canvasH = parseFloat(canvas.style.height);
const cellW = canvasW / COLS;
const cellH = canvasH / ROWS;
const charSize = Math.min(cellW * 1.3, cellH * 1.1);
const charAspect = cellW / cellH;
ctx.clearRect(0, 0, canvasW, canvasH);
ctx.font = `${charSize}px ui-monospace, "JetBrains Mono", "Menlo", "Consolas", monospace`;
ctx.textBaseline = "alphabetic";
const state = stateSel.value;
const t = animationTime();
const frame = {
cols: COLS,
rows: ROWS,
charAspect,
state,
time: t,
colorPhase: colorPhaseAt(t),
breatheSpeed: tw.breatheSpeed.current,
breatheAmp: tw.breatheAmp.current,
lightSpeedX: tw.lightSpeedX.current,
lightSpeedY: tw.lightSpeedY.current,
lightInfluence: tw.lightInfluence.current,
coreTightness: tw.coreTightness.current,
turbulenceAmp: tw.turbulenceAmp.current,
rippleScale: tw.rippleScale.current,
heartbeatSpeed: tw.heartbeatSpeed.current,
radialFlowSpeed: tw.radialFlowSpeed.current,
cr1: tw.cr1.current, cg1: tw.cg1.current, cb1: tw.cb1.current,
cr2: tw.cr2.current, cg2: tw.cg2.current, cb2: tw.cb2.current,
intensity: parseFloat(intensityEl.value),
toolCallBurst: parseFloat(toolBurstEl.value),
voiceAmplitude: parseFloat(voiceAmpEl.value),
voiceMode: voiceModeEl.checked,
voiceRadiusScale: tw.voiceRadiusScale.current,
};
forEachSphereCell(frame, (col, row, ch, r, g, b, a) => {
// Same placement as Android: baseline at 80% of cell height; left edge at col*cellW.
const px = col * cellW;
const py = row * cellH + cellH * 0.8;
ctx.fillStyle = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a.toFixed(3)})`;
ctx.fillText(ch, px, py);
});
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
// MorphingSphere — JavaScript port.
//
// This is a line-for-line mirror of MorphingSphereCore.kt. Keep them in sync.
// No build step, no dependencies — loaded directly by index.html.
// ── State + params ───────────────────────────────────────────────────
export const SphereState = Object.freeze({
Idle: "Idle",
Thinking: "Thinking",
Streaming: "Streaming",
Listening: "Listening",
Speaking: "Speaking",
Error: "Error",
});
export function paramsFor(state) {
switch (state) {
case SphereState.Idle:
return { breatheSpeed: 0.5, breatheAmp: 0.04, lightSpeedX: 0.25, lightSpeedY: 0.18, lightInfluence: 0.35, coreTightness: 0.75, turbulenceAmp: 0.06, rippleScale: 1.0, heartbeatSpeed: 1.0, radialFlowSpeed: 0.2 };
case SphereState.Thinking:
return { breatheSpeed: 0.8, breatheAmp: 0.02, lightSpeedX: 0.5, lightSpeedY: 0.35, lightInfluence: 0.30, coreTightness: 0.90, turbulenceAmp: 0.12, rippleScale: 1.5, heartbeatSpeed: 4.0, radialFlowSpeed: 0.1 };
case SphereState.Streaming:
return { breatheSpeed: 0.3, breatheAmp: 0.06, lightSpeedX: 0.15, lightSpeedY: 0.10, lightInfluence: 0.25, coreTightness: 0.60, turbulenceAmp: 0.08, rippleScale: 2.0, heartbeatSpeed: 1.5, radialFlowSpeed: 0.5 };
case SphereState.Listening:
return { breatheSpeed: 0.55, breatheAmp: 0.035, lightSpeedX: 0.22, lightSpeedY: 0.16, lightInfluence: 0.38, coreTightness: 0.78, turbulenceAmp: 0.05, rippleScale: 0.9, heartbeatSpeed: 1.2, radialFlowSpeed: 0.18 };
case SphereState.Speaking:
return { breatheSpeed: 0.45, breatheAmp: 0.05, lightSpeedX: 0.20, lightSpeedY: 0.14, lightInfluence: 0.30, coreTightness: 0.55, turbulenceAmp: 0.07, rippleScale: 1.8, heartbeatSpeed: 1.8, radialFlowSpeed: 0.45 };
case SphereState.Error:
return { breatheSpeed: 1.2, breatheAmp: 0.03, lightSpeedX: 0.7, lightSpeedY: 0.6, lightInfluence: 0.40, coreTightness: 0.80, turbulenceAmp: 0.15, rippleScale: 0.5, heartbeatSpeed: 6.0, radialFlowSpeed: 0.3 };
}
throw new Error(`Unknown state: ${state}`);
}
export function colorsFor(state) {
switch (state) {
case SphereState.Idle: return { r1: 0.25, g1: 0.85, b1: 0.40, r2: 0.61, g2: 0.42, b2: 0.94 };
case SphereState.Thinking: return { r1: 0.30, g1: 0.55, b1: 0.95, r2: 0.55, g2: 0.35, b2: 0.90 };
case SphereState.Streaming: return { r1: 0.20, g1: 0.90, b1: 0.50, r2: 0.25, g2: 0.80, b2: 0.85 };
case SphereState.Listening: return { r1: 0.35, g1: 0.55, b1: 0.95, r2: 0.65, g2: 0.45, b2: 0.95 };
case SphereState.Speaking: return { r1: 0.25, g1: 0.92, b1: 0.55, r2: 0.30, g2: 0.85, b2: 0.88 };
case SphereState.Error: return { r1: 0.90, g1: 0.30, b1: 0.25, r2: 0.85, g2: 0.50, b2: 0.20 };
}
throw new Error(`Unknown state: ${state}`);
}
// ── Math helpers ─────────────────────────────────────────────────────
export function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
function atLeast(x, lo) { return Math.max(lo, x); }
// 32-bit int hash mirroring the Kotlin implementation exactly.
// Math.imul + |0 force 32-bit signed overflow semantics.
function hash(x, y) {
let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177) | 0;
h = h ^ (h >>> 16);
return (h & 0x7fffffff) / 2147483647;
}
function smoothNoise(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const xf = x - xi;
const yf = y - yi;
const u = xf * xf * (3 - 2 * xf);
const v = yf * yf * (3 - 2 * yf);
const n00 = hash(xi, yi);
const n10 = hash(xi + 1, yi);
const n01 = hash(xi, yi + 1);
const n11 = hash(xi + 1, yi + 1);
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v);
}
export function fbm(x, y, octaves = 3) {
let value = 0, amplitude = 0.5, frequency = 1;
for (let i = 0; i < octaves; i++) {
value += amplitude * smoothNoise(x * frequency, y * frequency);
amplitude *= 0.5;
frequency *= 2;
}
return value;
}
// ── Sphere iteration ─────────────────────────────────────────────────
const CHAR_SETS = [
" ·:;=+*#%@",
" .:;=+*#%@",
" ·;:+=*%#@",
" .:;+=*#@%",
];
const DATA_CHARS = "01<>[]{}|/\\~^";
/**
* Mirrors `forEachSphereCell` in MorphingSphereCore.kt.
*
* @param frame - See SphereFrame shape in the Kotlin core.
* @param onCell - Invoked as (col, row, char, r, g, b, alpha). RGB and alpha are 0..1.
*/
export function forEachSphereCell(frame, onCell) {
const amp = clamp(frame.voiceAmplitude, 0, 1);
// ── Voice modulation ────────────────────────────────────────
let breatheSpeed = frame.breatheSpeed;
let turbulenceAmp = frame.turbulenceAmp;
let coreWarmth = 0.30;
let wobbleAmplitude = 0.06;
let dataRingSpeed = 0.4;
if (frame.state === SphereState.Listening) {
breatheSpeed = lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3, amp * 0.5);
turbulenceAmp = frame.turbulenceAmp + amp * 0.15;
wobbleAmplitude = 0.06 * (1 + amp * 0.3);
} else if (frame.state === SphereState.Speaking) {
breatheSpeed = lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0, amp);
turbulenceAmp = frame.turbulenceAmp + amp * 0.5;
coreWarmth = lerp(0.30, 1.0, amp);
wobbleAmplitude = 0.06 * (1 + amp * 0.8);
dataRingSpeed = 0.4 * (1 + amp * 3);
}
const cx = frame.cols / 2;
const cy = frame.rows / 2;
const charAspect = frame.charAspect;
const maxRadiusFromRows = (frame.rows / 2) * 0.60;
const maxRadiusFromCols = (frame.cols / 2) * charAspect * 0.60;
const baseRadius = Math.min(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale;
const t = frame.time;
const breathe = Math.sin(t * breatheSpeed) * frame.breatheAmp;
const breathingRadius = baseRadius * (1 + breathe);
const noiseJitter1 = fbm(t * 0.05 + 7.3, 1.7) * 0.5;
const noiseJitter2 = fbm(3.1, t * 0.04 + 13.7) * 0.5;
const lightAngle1 = t * frame.lightSpeedX + noiseJitter1;
const lightAngle2 = t * frame.lightSpeedY + noiseJitter2;
const lx = Math.sin(lightAngle1) * 0.65;
const ly = Math.cos(lightAngle2) * 0.65;
const lz = Math.sqrt(atLeast(1 - lx * lx - ly * ly, 0.01));
const heartbeat = Math.sin(t * frame.heartbeatSpeed) * 0.5 + 0.5;
const pulse = Math.sin(frame.colorPhase) * 0.5 + 0.5;
const colR = lerp(frame.cr1, frame.cr2, pulse);
const colG = lerp(frame.cg1, frame.cg2, pulse);
const colB = lerp(frame.cb1, frame.cb2, pulse);
const distWeight = 1 - frame.lightInfluence;
const effTurbulence = turbulenceAmp + frame.intensity * 0.04 + frame.toolCallBurst * 0.15;
const effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3;
const effRipple = frame.rippleScale + frame.intensity * 0.5 + frame.toolCallBurst * 1.0;
for (let row = 0; row < frame.rows; row++) {
for (let col = 0; col < frame.cols; col++) {
const dx = (col - cx) * charAspect;
const dy = (row - cy);
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
const perimeterNoise = fbm(
angle * 1.8 + t * 0.08,
angle * 0.7 + t * 0.12
) * 2 - 1;
const distortedRadius = breathingRadius * (1 + perimeterNoise * wobbleAmplitude);
const glowRadius = distortedRadius * 1.35;
const dataRingInner = distortedRadius * 1.40;
const dataRingOuter = distortedRadius * 1.55;
const normDist = dist / distortedRadius;
if (dist > dataRingOuter) continue;
if (normDist <= 1) {
// ── INSIDE SPHERE ────────────────────────────
const nx = dx / distortedRadius;
const ny2 = dy / distortedRadius;
const nzSq = atLeast(1 - nx * nx - ny2 * ny2, 0);
const nz = Math.sqrt(nzSq);
const distBrightness = atLeast(1 - normDist * normDist * frame.coreTightness, 0.15);
const directionalLight = clamp(nx * lx + ny2 * ly + nz * lz, 0, 1);
const structural = fbm(col * 0.25 + t * 0.18, row * 0.25 + t * 0.13, 2) * 0.15 - 0.075;
const turbulence = fbm(col * 0.8 + t * 0.6, row * 0.8 + t * 0.45, 2) * effTurbulence - effTurbulence * 0.5;
const radialFlow = fbm(angle * 2 + t * 0.15, dist * 0.3 - t * effRadialFlow, 2) * 0.06 - 0.03;
const ripple = (
Math.sin(normDist * 8 - t * 1.2) * 0.04 * (1 - normDist) +
Math.sin(normDist * 5 - t * 0.7 + 2) * 0.03 * (1 - normDist)
) * effRipple;
const heartbeatFx = heartbeat * 0.05 * (1 - normDist * normDist);
const brightness = distWeight * distBrightness + frame.lightInfluence * directionalLight + heartbeatFx;
const charNoise = structural + turbulence + radialFlow + ripple;
// Kotlin: `(t * 0.3f + col * 0.17f + row * 0.13f).toInt()` — truncation toward zero.
const rotationPhase = Math.trunc(t * 0.3 + col * 0.17 + row * 0.13);
const chars = CHAR_SETS[rotationPhase & 3];
const charIdx = clamp(Math.trunc((brightness + charNoise) * (chars.length - 1)), 1, chars.length - 1);
const ch = chars[charIdx];
let edgeFade = 1;
if (normDist > 0.80) {
const ef = (normDist - 0.80) / 0.20;
edgeFade = 1 - ef * ef;
}
const scanline = (row % 2 === 1) ? 0.82 : 1;
const alpha = clamp((brightness * 0.4 + 0.6) * edgeFade * scanline, 0.1, 1);
const warmth = (1 - normDist * normDist) * (coreWarmth * 0.40);
const lightBoost = directionalLight * 0.08;
onCell(col, row, ch,
clamp(colR + lightBoost + warmth, 0, 1),
clamp(colG + lightBoost * 0.5 + warmth, 0, 1),
clamp(colB + lightBoost + warmth, 0, 1),
alpha);
} else if (dist <= glowRadius) {
// ── GLOW / DEBRIS ZONE ───────────────────────
const glowT = (dist - distortedRadius) / (glowRadius - distortedRadius);
const glowFalloff = clamp(1 - glowT, 0, 1);
const sparsityNoise = fbm(angle * 3.5 + t * 0.25, dist * 0.4 + t * 0.08, 2);
const sparsityThreshold = 0.35 + glowT * 0.25;
if (sparsityNoise < sparsityThreshold) continue;
const debrisChars = "·:;- ";
const debrisIdx = clamp(Math.trunc((1 - glowFalloff) * (debrisChars.length - 1)), 0, debrisChars.length - 1);
const ch = debrisChars[debrisIdx];
if (ch === ' ') continue;
const alpha = glowFalloff * 0.85;
onCell(col, row, ch, clamp(colR, 0, 1), clamp(colG, 0, 1), clamp(colB, 0, 1), alpha);
} else if (dist >= dataRingInner) {
// ── DATA RING ────────────────────────────────
const ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner);
const orbitAngle = angle - t * dataRingSpeed + ringT * 1.5;
const ringNoise = fbm(orbitAngle * 4 + t * 0.3, ringT * 3 + t * 0.15, 2);
if (ringNoise < 0.55) continue;
// Kotlin `.mod(n)` is floored-positive modulo.
const rawIdx = Math.trunc((orbitAngle * 2 + t * 0.5) * DATA_CHARS.length);
const dataIdx = ((rawIdx % DATA_CHARS.length) + DATA_CHARS.length) % DATA_CHARS.length;
const ch = DATA_CHARS[dataIdx];
const ringFade = clamp(1 - ringT, 0, 1);
const alpha = ringFade * 0.65;
onCell(col, row, ch,
clamp(colR * 0.85, 0, 1),
clamp(colG * 0.85, 0, 1),
clamp(colB * 0.85, 0, 1),
alpha);
}
}
}
}