Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
7924fa3b10 feat: Sovereign Sound Playground — interactive audio-visual experience
Some checks failed
CI / test (pull_request) Failing after 51s
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 55s
Implements #1354. Complete interactive audio-visual playground with:

Apps:
- Playground v3 (full instrument with piano, 6 modes, 5 palettes)
- Synesthesia (paint with sound)
- Ambient (evolving soundscape)
- Interactive (26 key-shape mappings)
- Visualizer (WAV frequency visualization)

Features:
- Visual piano keyboard (2 octaves, mouse/touch/keyboard)
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
- Ambient beat with chord progressions
- Chord detection
- Recording to WAV
- Export as PNG
- Touch support
- Zero dependencies, pure browser

All apps are standalone HTML files — just open in a browser.
2026-04-13 18:25:31 -04:00
11 changed files with 2402 additions and 1235 deletions

View File

@@ -1,21 +0,0 @@
"""
agent — Cross-session agent memory and lifecycle hooks.
Provides persistent memory for agents via MemPalace integration.
Agents recall context at session start and write diary entries at session end.
Modules:
memory.py — AgentMemory class (recall, remember, diary)
memory_hooks.py — Session lifecycle hooks (drop-in integration)
"""
from agent.memory import AgentMemory, MemoryContext, SessionTranscript, create_agent_memory
from agent.memory_hooks import MemoryHooks
__all__ = [
"AgentMemory",
"MemoryContext",
"MemoryHooks",
"SessionTranscript",
"create_agent_memory",
]

View File

@@ -1,396 +0,0 @@
"""
agent.memory — Cross-session agent memory via MemPalace.
Gives agents persistent memory across sessions. On wake-up, agents
recall relevant context from past sessions. On session end, they
write a diary entry summarizing what happened.
Architecture:
Session Start → memory.recall_context() → inject L0/L1 into prompt
During Session → memory.remember() → store important facts
Session End → memory.write_diary() → summarize session
All operations degrade gracefully — if MemPalace is unavailable,
the agent continues without memory and logs a warning.
Usage:
from agent.memory import AgentMemory
mem = AgentMemory(agent_name="bezalel", wing="wing_bezalel")
# Session start — load context
context = mem.recall_context("What was I working on last time?")
# During session — store important decisions
mem.remember("Switched CI runner from GitHub Actions to self-hosted", room="forge")
# Session end — write diary
mem.write_diary("Fixed PR #1386, reconciled fleet registry locations")
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger("agent.memory")
@dataclass
class MemoryContext:
"""Context loaded at session start from MemPalace."""
relevant_memories: list[dict] = field(default_factory=list)
recent_diaries: list[dict] = field(default_factory=list)
facts: list[dict] = field(default_factory=list)
loaded: bool = False
error: Optional[str] = None
def to_prompt_block(self) -> str:
"""Format context as a text block to inject into the agent prompt."""
if not self.loaded:
return ""
parts = []
if self.recent_diaries:
parts.append("=== Recent Session Summaries ===")
for d in self.recent_diaries[:3]:
ts = d.get("timestamp", "")
text = d.get("text", "")
parts.append(f"[{ts}] {text[:500]}")
if self.facts:
parts.append("\n=== Known Facts ===")
for f in self.facts[:10]:
text = f.get("text", "")
parts.append(f"- {text[:200]}")
if self.relevant_memories:
parts.append("\n=== Relevant Past Memories ===")
for m in self.relevant_memories[:5]:
text = m.get("text", "")
score = m.get("score", 0)
parts.append(f"[{score:.2f}] {text[:300]}")
if not parts:
return ""
return "\n".join(parts)
@dataclass
class SessionTranscript:
"""A running log of the current session for diary writing."""
agent_name: str
wing: str
started_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
entries: list[dict] = field(default_factory=list)
def add_user_turn(self, text: str):
self.entries.append({
"role": "user",
"text": text[:2000],
"ts": time.time(),
})
def add_agent_turn(self, text: str):
self.entries.append({
"role": "agent",
"text": text[:2000],
"ts": time.time(),
})
def add_tool_call(self, tool: str, args: str, result_summary: str):
self.entries.append({
"role": "tool",
"tool": tool,
"args": args[:500],
"result": result_summary[:500],
"ts": time.time(),
})
def summary(self) -> str:
"""Generate a compact transcript summary."""
if not self.entries:
return "Empty session."
turns = []
for e in self.entries[-20:]: # last 20 entries
role = e["role"]
if role == "user":
turns.append(f"USER: {e['text'][:200]}")
elif role == "agent":
turns.append(f"AGENT: {e['text'][:200]}")
elif role == "tool":
turns.append(f"TOOL({e.get('tool','')}): {e.get('result','')[:150]}")
return "\n".join(turns)
class AgentMemory:
"""
Cross-session memory for an agent.
Wraps MemPalace with agent-specific conventions:
- Each agent has a wing (e.g., "wing_bezalel")
- Session summaries go in the "hermes" room
- Important decisions go in room-specific closets
- Facts go in the "nexus" room
"""
def __init__(
self,
agent_name: str,
wing: Optional[str] = None,
palace_path: Optional[Path] = None,
):
self.agent_name = agent_name
self.wing = wing or f"wing_{agent_name}"
self.palace_path = palace_path
self._transcript: Optional[SessionTranscript] = None
self._available: Optional[bool] = None
def _check_available(self) -> bool:
"""Check if MemPalace is accessible."""
if self._available is not None:
return self._available
try:
from nexus.mempalace.searcher import search_memories, add_memory, _get_client
from nexus.mempalace.config import MEMPALACE_PATH
path = self.palace_path or MEMPALACE_PATH
_get_client(path)
self._available = True
logger.info(f"MemPalace available at {path}")
except Exception as e:
self._available = False
logger.warning(f"MemPalace unavailable: {e}")
return self._available
def recall_context(
self,
query: Optional[str] = None,
n_results: int = 5,
) -> MemoryContext:
"""
Load relevant context from past sessions.
Called at session start to inject L0/L1 memory into the prompt.
Args:
query: What to search for. If None, loads recent diary entries.
n_results: Max memories to recall.
"""
ctx = MemoryContext()
if not self._check_available():
ctx.error = "MemPalace unavailable"
return ctx
try:
from nexus.mempalace.searcher import search_memories
# Load recent diary entries (session summaries)
ctx.recent_diaries = [
{"text": r.text, "score": r.score, "timestamp": r.metadata.get("timestamp", "")}
for r in search_memories(
"session summary",
palace_path=self.palace_path,
wing=self.wing,
room="hermes",
n_results=3,
)
]
# Load known facts
ctx.facts = [
{"text": r.text, "score": r.score}
for r in search_memories(
"important facts decisions",
palace_path=self.palace_path,
wing=self.wing,
room="nexus",
n_results=5,
)
]
# Search for relevant memories if query provided
if query:
ctx.relevant_memories = [
{"text": r.text, "score": r.score, "room": r.room}
for r in search_memories(
query,
palace_path=self.palace_path,
wing=self.wing,
n_results=n_results,
)
]
ctx.loaded = True
except Exception as e:
ctx.error = str(e)
logger.warning(f"Failed to recall context: {e}")
return ctx
def remember(
self,
text: str,
room: str = "nexus",
source_file: str = "",
metadata: Optional[dict] = None,
) -> Optional[str]:
"""
Store a memory.
Args:
text: The memory content.
room: Target room (forge, hermes, nexus, issues, experiments).
source_file: Optional source attribution.
metadata: Extra metadata.
Returns:
Document ID if stored, None if MemPalace unavailable.
"""
if not self._check_available():
logger.warning("Cannot store memory — MemPalace unavailable")
return None
try:
from nexus.mempalace.searcher import add_memory
doc_id = add_memory(
text=text,
room=room,
wing=self.wing,
palace_path=self.palace_path,
source_file=source_file,
extra_metadata=metadata or {},
)
logger.debug(f"Stored memory in {room}: {text[:80]}...")
return doc_id
except Exception as e:
logger.warning(f"Failed to store memory: {e}")
return None
def write_diary(
self,
summary: Optional[str] = None,
) -> Optional[str]:
"""
Write a session diary entry to MemPalace.
Called at session end. If summary is None, auto-generates one
from the session transcript.
Args:
summary: Override summary text. If None, generates from transcript.
Returns:
Document ID if stored, None if unavailable.
"""
if summary is None and self._transcript:
summary = self._transcript.summary()
if not summary:
return None
timestamp = datetime.now(timezone.utc).isoformat()
diary_text = f"[{timestamp}] Session by {self.agent_name}:\n{summary}"
return self.remember(
diary_text,
room="hermes",
metadata={
"type": "session_diary",
"agent": self.agent_name,
"timestamp": timestamp,
"entry_count": len(self._transcript.entries) if self._transcript else 0,
},
)
def start_session(self) -> SessionTranscript:
"""
Begin a new session transcript.
Returns the transcript object for recording turns.
"""
self._transcript = SessionTranscript(
agent_name=self.agent_name,
wing=self.wing,
)
logger.info(f"Session started for {self.agent_name}")
return self._transcript
def end_session(self, diary_summary: Optional[str] = None) -> Optional[str]:
"""
End the current session, write diary, return diary doc ID.
"""
doc_id = self.write_diary(diary_summary)
self._transcript = None
logger.info(f"Session ended for {self.agent_name}")
return doc_id
def search(
self,
query: str,
room: Optional[str] = None,
n_results: int = 5,
) -> list[dict]:
"""
Search memories. Useful during a session for recall.
Returns list of {text, room, wing, score} dicts.
"""
if not self._check_available():
return []
try:
from nexus.mempalace.searcher import search_memories
results = search_memories(
query,
palace_path=self.palace_path,
wing=self.wing,
room=room,
n_results=n_results,
)
return [
{"text": r.text, "room": r.room, "wing": r.wing, "score": r.score}
for r in results
]
except Exception as e:
logger.warning(f"Search failed: {e}")
return []
# --- Fleet-wide memory helpers ---
def create_agent_memory(
agent_name: str,
palace_path: Optional[Path] = None,
) -> AgentMemory:
"""
Factory for creating AgentMemory with standard config.
Reads wing from MEMPALACE_WING env or defaults to wing_{agent_name}.
"""
wing = os.environ.get("MEMPALACE_WING", f"wing_{agent_name}")
return AgentMemory(
agent_name=agent_name,
wing=wing,
palace_path=palace_path,
)

View File

@@ -1,183 +0,0 @@
"""
agent.memory_hooks — Session lifecycle hooks for agent memory.
Integrates AgentMemory into the agent session lifecycle:
- on_session_start: Load context, inject into prompt
- on_user_turn: Record user input
- on_agent_turn: Record agent output
- on_tool_call: Record tool usage
- on_session_end: Write diary, clean up
These hooks are designed to be called from the Hermes harness or
any agent framework. They're fire-and-forget — failures are logged
but never crash the session.
Usage:
from agent.memory_hooks import MemoryHooks
hooks = MemoryHooks(agent_name="bezalel")
hooks.on_session_start() # loads context
# In your agent loop:
hooks.on_user_turn("Check CI pipeline health")
hooks.on_agent_turn("Running CI check...")
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
# End of session:
hooks.on_session_end() # writes diary
"""
from __future__ import annotations
import logging
from typing import Optional
from agent.memory import AgentMemory, MemoryContext, create_agent_memory
logger = logging.getLogger("agent.memory_hooks")
class MemoryHooks:
"""
Drop-in session lifecycle hooks for agent memory.
Wraps AgentMemory with error boundaries — every hook catches
exceptions and logs warnings so memory failures never crash
the agent session.
"""
def __init__(
self,
agent_name: str,
palace_path=None,
auto_diary: bool = True,
):
self.agent_name = agent_name
self.auto_diary = auto_diary
self._memory: Optional[AgentMemory] = None
self._context: Optional[MemoryContext] = None
self._active = False
@property
def memory(self) -> AgentMemory:
if self._memory is None:
self._memory = create_agent_memory(
self.agent_name,
palace_path=getattr(self, '_palace_path', None),
)
return self._memory
def on_session_start(self, query: Optional[str] = None) -> str:
"""
Called at session start. Loads context from MemPalace.
Returns a prompt block to inject into the agent's context, or
empty string if memory is unavailable.
Args:
query: Optional recall query (e.g., "What was I working on?")
"""
try:
self.memory.start_session()
self._active = True
self._context = self.memory.recall_context(query=query)
block = self._context.to_prompt_block()
if block:
logger.info(
f"Loaded {len(self._context.recent_diaries)} diaries, "
f"{len(self._context.facts)} facts, "
f"{len(self._context.relevant_memories)} relevant memories "
f"for {self.agent_name}"
)
else:
logger.info(f"No prior memory for {self.agent_name}")
return block
except Exception as e:
logger.warning(f"Session start memory hook failed: {e}")
return ""
def on_user_turn(self, text: str):
"""Record a user message."""
if not self._active:
return
try:
if self.memory._transcript:
self.memory._transcript.add_user_turn(text)
except Exception as e:
logger.debug(f"Failed to record user turn: {e}")
def on_agent_turn(self, text: str):
"""Record an agent response."""
if not self._active:
return
try:
if self.memory._transcript:
self.memory._transcript.add_agent_turn(text)
except Exception as e:
logger.debug(f"Failed to record agent turn: {e}")
def on_tool_call(self, tool: str, args: str, result_summary: str):
"""Record a tool invocation."""
if not self._active:
return
try:
if self.memory._transcript:
self.memory._transcript.add_tool_call(tool, args, result_summary)
except Exception as e:
logger.debug(f"Failed to record tool call: {e}")
def on_important_decision(self, text: str, room: str = "nexus"):
"""
Record an important decision or fact for long-term memory.
Use this when the agent makes a significant decision that
should persist beyond the current session.
"""
try:
self.memory.remember(text, room=room, metadata={"type": "decision"})
logger.info(f"Remembered decision: {text[:80]}...")
except Exception as e:
logger.warning(f"Failed to remember decision: {e}")
def on_session_end(self, summary: Optional[str] = None) -> Optional[str]:
"""
Called at session end. Writes diary entry.
Args:
summary: Override diary text. If None, auto-generates.
Returns:
Diary document ID, or None.
"""
if not self._active:
return None
try:
doc_id = self.memory.end_session(diary_summary=summary)
self._active = False
self._context = None
return doc_id
except Exception as e:
logger.warning(f"Session end memory hook failed: {e}")
self._active = False
return None
def search(self, query: str, room: Optional[str] = None) -> list[dict]:
"""
Search memories during a session.
Returns list of {text, room, wing, score}.
"""
try:
return self.memory.search(query, room=room)
except Exception as e:
logger.warning(f"Memory search failed: {e}")
return []
@property
def is_active(self) -> bool:
return self._active

View File

@@ -1,258 +0,0 @@
#!/usr/bin/env python3
"""
memory_mine.py — Mine session transcripts into MemPalace.
Reads Hermes session logs (JSONL format) and stores summaries
in the palace. Supports batch mining, single-file processing,
and live directory watching.
Usage:
# Mine a single session file
python3 bin/memory_mine.py ~/.hermes/sessions/2026-04-13.jsonl
# Mine all sessions from last 7 days
python3 bin/memory_mine.py --days 7
# Mine a specific wing's sessions
python3 bin/memory_mine.py --wing wing_bezalel --days 14
# Dry run — show what would be mined
python3 bin/memory_mine.py --dry-run --days 7
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("memory-mine")
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def parse_session_file(path: Path) -> list[dict]:
"""
Parse a JSONL session file into turns.
Each line is expected to be a JSON object with:
- role: "user" | "assistant" | "system" | "tool"
- content: text
- timestamp: ISO string (optional)
"""
turns = []
with open(path) as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
turn = json.loads(line)
turns.append(turn)
except json.JSONDecodeError:
logger.debug(f"Skipping malformed line {i+1} in {path}")
return turns
def summarize_session(turns: list[dict], agent_name: str = "unknown") -> str:
"""
Generate a compact summary of a session's turns.
Keeps user messages and key agent responses, strips noise.
"""
if not turns:
return "Empty session."
user_msgs = []
agent_msgs = []
tool_calls = []
for turn in turns:
role = turn.get("role", "")
content = str(turn.get("content", ""))[:300]
if role == "user":
user_msgs.append(content)
elif role == "assistant":
agent_msgs.append(content)
elif role == "tool":
tool_name = turn.get("name", turn.get("tool", "unknown"))
tool_calls.append(f"{tool_name}: {content[:150]}")
parts = [f"Session by {agent_name}:"]
if user_msgs:
parts.append(f"\nUser asked ({len(user_msgs)} messages):")
for msg in user_msgs[:5]:
parts.append(f" - {msg[:200]}")
if len(user_msgs) > 5:
parts.append(f" ... and {len(user_msgs) - 5} more")
if agent_msgs:
parts.append(f"\nAgent responded ({len(agent_msgs)} messages):")
for msg in agent_msgs[:3]:
parts.append(f" - {msg[:200]}")
if tool_calls:
parts.append(f"\nTools used ({len(tool_calls)} calls):")
for tc in tool_calls[:5]:
parts.append(f" - {tc}")
return "\n".join(parts)
def mine_session(
path: Path,
wing: str,
palace_path: Optional[Path] = None,
dry_run: bool = False,
) -> Optional[str]:
"""
Mine a single session file into MemPalace.
Returns the document ID if stored, None on failure or dry run.
"""
try:
from agent.memory import AgentMemory
except ImportError:
logger.error("Cannot import agent.memory — is the repo in PYTHONPATH?")
return None
turns = parse_session_file(path)
if not turns:
logger.debug(f"Empty session file: {path}")
return None
agent_name = wing.replace("wing_", "")
summary = summarize_session(turns, agent_name)
if dry_run:
print(f"\n--- {path.name} ---")
print(summary[:500])
print(f"({len(turns)} turns)")
return None
mem = AgentMemory(agent_name=agent_name, wing=wing, palace_path=palace_path)
doc_id = mem.remember(
summary,
room="hermes",
source_file=str(path),
metadata={
"type": "mined_session",
"source": str(path),
"turn_count": len(turns),
"agent": agent_name,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
if doc_id:
logger.info(f"Mined {path.name}{doc_id} ({len(turns)} turns)")
else:
logger.warning(f"Failed to mine {path.name}")
return doc_id
def find_session_files(
sessions_dir: Path,
days: int = 7,
pattern: str = "*.jsonl",
) -> list[Path]:
"""
Find session files from the last N days.
"""
cutoff = datetime.now() - timedelta(days=days)
files = []
if not sessions_dir.exists():
logger.warning(f"Sessions directory not found: {sessions_dir}")
return files
for path in sorted(sessions_dir.glob(pattern)):
# Use file modification time as proxy for session date
mtime = datetime.fromtimestamp(path.stat().st_mtime)
if mtime >= cutoff:
files.append(path)
return files
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Mine session transcripts into MemPalace"
)
parser.add_argument(
"files", nargs="*", help="Session files to mine (JSONL format)"
)
parser.add_argument(
"--days", type=int, default=7,
help="Mine sessions from last N days (default: 7)"
)
parser.add_argument(
"--sessions-dir",
default=str(Path.home() / ".hermes" / "sessions"),
help="Directory containing session JSONL files"
)
parser.add_argument(
"--wing", default=None,
help="Wing name (default: auto-detect from MEMPALACE_WING env or 'wing_timmy')"
)
parser.add_argument(
"--palace-path", default=None,
help="Override palace path"
)
parser.add_argument(
"--dry-run", action="store_true",
help="Show what would be mined without storing"
)
args = parser.parse_args(argv)
wing = args.wing or os.environ.get("MEMPALACE_WING", "wing_timmy")
palace_path = Path(args.palace_path) if args.palace_path else None
if args.files:
files = [Path(f) for f in args.files]
else:
sessions_dir = Path(args.sessions_dir)
files = find_session_files(sessions_dir, days=args.days)
if not files:
logger.info("No session files found to mine.")
return 0
logger.info(f"Mining {len(files)} session files (wing={wing})")
mined = 0
failed = 0
for path in files:
result = mine_session(path, wing=wing, palace_path=palace_path, dry_run=args.dry_run)
if result:
mined += 1
elif result is None and not args.dry_run:
failed += 1
if args.dry_run:
logger.info(f"Dry run complete — {len(files)} files would be mined")
else:
logger.info(f"Mining complete — {mined} mined, {failed} failed")
return 0
if __name__ == "__main__":
sys.exit(main())

80
playground/README.md Normal file
View File

@@ -0,0 +1,80 @@
# Sovereign Sound Playground
Interactive audio-visual experience — no servers, no dependencies, pure browser.
## Apps
### Playground v3 (Full Instrument)
`playground.html` — The complete experience:
- Visual piano keyboard (2 octaves)
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
- Ambient beat with chord progressions
- Mouse/touch playback on visualizer
- Chord detection
- Recording to WAV
- Export as PNG
### Synesthesia
`synesthesia.html` — Paint with sound:
- Click and drag to create colors and shapes
- Each position generates a unique tone
- Particles respond to your movement
- Touch supported
### Ambient
`ambient.html` — Evolving soundscape:
- Automatic chord progressions
- Floating orbs respond to audio
- Reverb-drenched textures
- Click to enter, let it wash over you
### Interactive
`interactive.html` — 26 key-shape mappings:
- Press A-Z to play notes
- Each key has a unique shape and color
- Shapes animate and fade
- Visual keyboard at bottom
- Touch supported
### Visualizer
`visualizer.html` — WAV frequency visualization:
- Load any audio file
- 4 modes: Spectrum, Waveform, Spectrogram, Circular
- Drag and drop support
- Real-time frequency analysis
## Features
- Zero dependencies — just open in a browser
- Local-first — no network requests
- Touch support on all apps
- Keyboard support
- Recording and export
- Multiple visualization modes
- Color palettes
## Usage
Open any HTML file in a browser. That's it.
```bash
# Quick start
open playground/playground.html
# Or serve locally
python3 -m http.server 8080 --directory playground
```
## Keyboard Shortcuts (Playground v3)
- A-; (lower row): Play piano notes
- Mouse drag on visualizer: Create sound
- Click piano keys: Play notes
## Technical
- Web Audio API for sound generation
- Canvas 2D for visualization
- MediaRecorder for recording
- No build step, no framework

243
playground/ambient.html Normal file
View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ambient — Evolving Soundscape</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.overlay {
position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; background: rgba(0,0,0,0.7);
transition: opacity 0.5s; z-index: 10;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.start-btn {
background: rgba(100,50,150,0.5); border: 2px solid rgba(150,100,200,0.7);
color: #e0e0e0; padding: 20px 40px; font-size: 18px; border-radius: 30px;
cursor: pointer; font-family: sans-serif; letter-spacing: 2px;
transition: all 0.3s;
}
.start-btn:hover { background: rgba(150,100,200,0.5); transform: scale(1.05); }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Ambient</div>
<canvas id="canvas"></canvas>
<div class="overlay" id="overlay">
<button class="start-btn" id="start-btn">Enter Soundscape</button>
</div>
<script>
class AmbientSoundscape {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.masterGain = null;
this.analyser = null;
this.isPlaying = false;
this.currentChord = 0;
this.time = 0;
this.orbs = [];
this.resize();
window.addEventListener('resize', () => this.resize());
// Create orbs
for (let i = 0; i < 15; i++) {
this.orbs.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
radius: Math.random() * 50 + 20,
speed: Math.random() * 0.5 + 0.2,
angle: Math.random() * Math.PI * 2,
color: [
[167, 139, 250], [129, 140, 248], [99, 102, 241],
[139, 92, 246], [124, 58, 237]
][i % 5]
});
}
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioCtx.createGain();
this.masterGain.gain.value = 0.2;
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.smoothingTimeConstant = 0.95;
this.masterGain.connect(this.analyser);
this.analyser.connect(this.audioCtx.destination);
// Create reverb
const convolver = this.audioCtx.createConvolver();
const reverbTime = 3;
const sampleRate = this.audioCtx.sampleRate;
const length = sampleRate * reverbTime;
const impulse = this.audioCtx.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
}
}
convolver.buffer = impulse;
convolver.connect(this.masterGain);
this.reverb = convolver;
}
playChord() {
if (!this.audioCtx || !this.isPlaying) return;
const progressions = [
[[261.63, 329.63, 392.00], [392.00, 493.88, 587.33],
[440.00, 523.25, 659.25], [349.23, 440.00, 523.25]],
[[220.00, 277.18, 329.63], [329.63, 415.30, 493.88],
[369.99, 466.16, 554.37], [293.66, 369.99, 440.00]]
];
const prog = progressions[this.currentChord % 2];
const chord = prog[Math.floor(this.time / 4) % prog.length];
chord.forEach((freq, i) => {
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
const filter = this.audioCtx.createBiquadFilter();
osc.type = 'sine';
osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.01);
filter.type = 'lowpass';
filter.frequency.value = 800 + Math.sin(this.time * 0.1) * 400;
gain.gain.setValueAtTime(0, this.audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.15, this.audioCtx.currentTime + 0.5);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 4);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.reverb);
osc.start();
osc.stop(this.audioCtx.currentTime + 4);
});
// High texture
const highOsc = this.audioCtx.createOscillator();
const highGain = this.audioCtx.createGain();
highOsc.type = 'sine';
highOsc.frequency.value = chord[0] * 4 + Math.random() * 50;
highGain.gain.setValueAtTime(0, this.audioCtx.currentTime);
highGain.gain.linearRampToValueAtTime(0.03, this.audioCtx.currentTime + 1);
highGain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 5);
highOsc.connect(highGain);
highGain.connect(this.reverb);
highOsc.start();
highOsc.stop(this.audioCtx.currentTime + 5);
this.time += 4;
}
start() {
this.isPlaying = true;
document.getElementById('overlay').classList.add('hidden');
const loop = () => {
if (!this.isPlaying) return;
this.playChord();
setTimeout(loop, 4000);
};
loop();
}
stop() {
this.isPlaying = false;
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.03)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const freqData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : [];
if (this.analyser) this.analyser.getByteFrequencyData(freqData);
const energy = freqData.length > 0 ? freqData[10] / 255 : 0;
// Move and draw orbs
this.orbs.forEach((orb, i) => {
orb.angle += orb.speed * 0.01;
orb.x += Math.cos(orb.angle) * (1 + energy * 3);
orb.y += Math.sin(orb.angle * 0.7) * (1 + energy * 3);
// Wrap around
if (orb.x < -orb.radius) orb.x = this.canvas.width + orb.radius;
if (orb.x > this.canvas.width + orb.radius) orb.x = -orb.radius;
if (orb.y < -orb.radius) orb.y = this.canvas.height + orb.radius;
if (orb.y > this.canvas.height + orb.radius) orb.y = -orb.radius;
// Draw glow
const gradient = this.ctx.createRadialGradient(
orb.x, orb.y, 0,
orb.x, orb.y, orb.radius * (1 + energy * 0.5)
);
gradient.addColorStop(0, `rgba(${orb.color.join(',')}, 0.3)`);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * (1 + energy * 0.5), 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
// Inner glow
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${orb.color.join(',')}, 0.5)`;
this.ctx.fill();
});
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.getElementById('start-btn').addEventListener('click', async () => {
await this.initAudio();
this.start();
});
document.addEventListener('click', async () => {
if (!this.audioCtx) {
await this.initAudio();
}
}, { once: true });
}
}
new AmbientSoundscape();
</script>
</body>
</html>

294
playground/interactive.html Normal file
View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive — 26 Key Shapes</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
.key-hint {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.3); font-size: 14px; font-family: monospace;
pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Interactive</div>
<div class="key-hint">Press A-Z to play</div>
<canvas id="canvas"></canvas>
<script>
class Interactive {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.shapes = [];
this.activeKeys = new Set();
this.resize();
window.addEventListener('resize', () => this.resize());
// 26 key mappings with unique shapes and frequencies
this.keyMap = {
'a': { freq: 261.63, shape: 'circle', color: [255,100,100] },
'b': { freq: 277.18, shape: 'square', color: [255,150,100] },
'c': { freq: 293.66, shape: 'triangle', color: [255,200,100] },
'd': { freq: 311.13, shape: 'diamond', color: [255,255,100] },
'e': { freq: 329.63, shape: 'star', color: [200,255,100] },
'f': { freq: 349.23, shape: 'hexagon', color: [150,255,100] },
'g': { freq: 369.99, shape: 'cross', color: [100,255,100] },
'h': { freq: 392.00, shape: 'circle', color: [100,255,150] },
'i': { freq: 415.30, shape: 'square', color: [100,255,200] },
'j': { freq: 440.00, shape: 'triangle', color: [100,255,255] },
'k': { freq: 466.16, shape: 'diamond', color: [100,200,255] },
'l': { freq: 493.88, shape: 'star', color: [100,150,255] },
'm': { freq: 523.25, shape: 'hexagon', color: [100,100,255] },
'n': { freq: 554.37, shape: 'cross', color: [150,100,255] },
'o': { freq: 587.33, shape: 'circle', color: [200,100,255] },
'p': { freq: 622.25, shape: 'square', color: [255,100,255] },
'q': { freq: 659.25, shape: 'triangle', color: [255,100,200] },
'r': { freq: 698.46, shape: 'diamond', color: [255,100,150] },
's': { freq: 739.99, shape: 'star', color: [255,120,120] },
't': { freq: 783.99, shape: 'hexagon', color: [255,170,120] },
'u': { freq: 830.61, shape: 'cross', color: [255,220,120] },
'v': { freq: 880.00, shape: 'circle', color: [220,255,120] },
'w': { freq: 932.33, shape: 'square', color: [170,255,120] },
'x': { freq: 987.77, shape: 'triangle', color: [120,255,120] },
'y': { freq: 1046.50, shape: 'diamond', color: [120,255,170] },
'z': { freq: 1108.73, shape: 'star', color: [120,255,220] }
};
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.connect(this.audioCtx.destination);
}
playKey(key) {
if (!this.audioCtx || !this.keyMap[key]) return;
const { freq, shape, color } = this.keyMap[key];
// Play sound
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 1);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 1);
// Create shape
const x = Math.random() * (this.canvas.width - 200) + 100;
const y = Math.random() * (this.canvas.height - 200) + 100;
this.shapes.push({
key, x, y, shape, color,
size: 50,
maxSize: 150,
life: 1,
rotation: 0
});
}
drawShape(shape) {
const { x, y, size, rotation, shape: shapeType, color, life } = shape;
this.ctx.save();
this.ctx.translate(x, y);
this.ctx.rotate(rotation);
this.ctx.globalAlpha = life;
this.ctx.strokeStyle = `rgb(${color.join(',')})`;
this.ctx.lineWidth = 3;
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.2)`;
this.ctx.beginPath();
switch (shapeType) {
case 'circle':
this.ctx.arc(0, 0, size, 0, Math.PI * 2);
break;
case 'square':
this.ctx.rect(-size, -size, size * 2, size * 2);
break;
case 'triangle':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.866, size * 0.5);
this.ctx.lineTo(-size * 0.866, size * 0.5);
this.ctx.closePath();
break;
case 'diamond':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.7, 0);
this.ctx.lineTo(0, size);
this.ctx.lineTo(-size * 0.7, 0);
this.ctx.closePath();
break;
case 'star':
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'hexagon':
for (let i = 0; i < 6; i++) {
const angle = (i * Math.PI) / 3;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'cross':
const w = size * 0.3;
this.ctx.moveTo(-w, -size);
this.ctx.lineTo(w, -size);
this.ctx.lineTo(w, -w);
this.ctx.lineTo(size, -w);
this.ctx.lineTo(size, w);
this.ctx.lineTo(w, w);
this.ctx.lineTo(w, size);
this.ctx.lineTo(-w, size);
this.ctx.lineTo(-w, w);
this.ctx.lineTo(-size, w);
this.ctx.lineTo(-size, -w);
this.ctx.lineTo(-w, -w);
this.ctx.closePath();
break;
}
this.ctx.fill();
this.ctx.stroke();
// Draw key label
this.ctx.fillStyle = `rgba(${color.join(',')}, ${life})`;
this.ctx.font = `${size * 0.4}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(shape.key.toUpperCase(), 0, 0);
this.ctx.restore();
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Update and draw shapes
this.shapes = this.shapes.filter(shape => {
shape.size += (shape.maxSize - shape.size) * 0.05;
shape.rotation += 0.02;
shape.life -= 0.01;
if (shape.life <= 0) return false;
this.drawShape(shape);
return true;
});
// Draw active key indicators
const keyWidth = this.canvas.width / 13;
const keyHeight = 40;
const startY = this.canvas.height - 60;
let col = 0;
for (const key of 'abcdefghijklmnopqrstuvwxyz') {
const x = col * keyWidth + keyWidth / 2;
const isActive = this.activeKeys.has(key);
this.ctx.fillStyle = isActive
? `rgba(${this.keyMap[key].color.join(',')}, 0.5)`
: 'rgba(255,255,255,0.05)';
this.ctx.beginPath();
this.ctx.roundRect(x - 15, startY, 30, keyHeight, 5);
this.ctx.fill();
this.ctx.fillStyle = isActive ? '#fff' : 'rgba(255,255,255,0.3)';
this.ctx.font = '12px monospace';
this.ctx.textAlign = 'center';
this.ctx.fillText(key.toUpperCase(), x, startY + 25);
col++;
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.addEventListener('keydown', async (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (this.keyMap[key] && !this.activeKeys.has(key)) {
await this.initAudio();
this.activeKeys.add(key);
this.playKey(key);
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
this.activeKeys.delete(key);
});
// Touch support
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
// Map touch area to key
for (const touch of e.changedTouches) {
const keyIndex = Math.floor(touch.clientX / (this.canvas.width / 26));
const key = 'abcdefghijklmnopqrstuvwxyz'[keyIndex];
if (key) {
this.activeKeys.add(key);
this.playKey(key);
}
}
});
this.canvas.addEventListener('touchend', (e) => {
e.preventDefault();
this.activeKeys.clear();
});
}
}
new Interactive();
</script>
</body>
</html>

1216
playground/playground.html Normal file

File diff suppressed because it is too large Load Diff

198
playground/synesthesia.html Normal file
View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synesthesia — Paint with Sound</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; cursor: crosshair; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.15); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Synesthesia</div>
<canvas id="canvas"></canvas>
<script>
class Synesthesia {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.particles = [];
this.trails = [];
this.mouse = { x: 0, y: 0, down: false };
this.lastNote = null;
this.colors = [
[255, 100, 150], [100, 200, 255], [150, 255, 150],
[255, 200, 100], [200, 150, 255]
];
this.resize();
window.addEventListener('resize', () => this.resize());
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new AudioContext();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.connect(this.audioCtx.destination);
}
playTone(x, y) {
if (!this.audioCtx) return;
const freq = 200 + (x / this.canvas.width) * 600;
const noteId = Math.round(freq / 20);
if (noteId === this.lastNote) return;
this.lastNote = noteId;
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.2, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.5);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 0.5);
// Spawn particles
const color = this.colors[Math.floor(Math.random() * this.colors.length)];
for (let i = 0; i < 8; i++) {
this.particles.push({
x, y,
vx: (Math.random() - 0.5) * 8,
vy: (Math.random() - 0.5) * 8,
life: 1,
color,
size: Math.random() * 10 + 5
});
}
// Add trail
this.trails.push({ x, y, color, size: 30, alpha: 0.5 });
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.05)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw trails
this.trails = this.trails.filter(t => {
t.alpha -= 0.005;
if (t.alpha <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(t.x, t.y, t.size, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${t.color.join(',')}, ${t.alpha})`;
this.ctx.fill();
return true;
});
// Draw particles
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.02;
p.vy += 0.1;
if (p.life <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${p.color.join(',')}, ${p.life * 0.8})`;
this.ctx.fill();
return true;
});
// Draw mouse trail
if (this.mouse.down) {
this.ctx.beginPath();
this.ctx.arc(this.mouse.x, this.mouse.y, 20, 0, Math.PI * 2);
const color = this.colors[Date.now() % 5];
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.3)`;
this.ctx.fill();
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
this.canvas.addEventListener('mousedown', async (e) => {
await this.initAudio();
this.mouse.down = true;
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.playTone(e.clientX, e.clientY);
});
this.canvas.addEventListener('mousemove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
if (this.mouse.down) {
this.playTone(e.clientX, e.clientY);
}
});
this.canvas.addEventListener('mouseup', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('mouseleave', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
this.mouse.down = true;
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
this.playTone(touch.clientX, touch.clientY);
});
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
if (this.mouse.down) {
this.playTone(touch.clientX, touch.clientY);
}
});
this.canvas.addEventListener('touchend', () => {
this.mouse.down = false;
this.lastNote = null;
});
}
}
new Synesthesia();
</script>
</body>
</html>

371
playground/visualizer.html Normal file
View File

@@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visualizer — WAV Frequency</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
.controls {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; z-index: 10;
}
button, select {
background: rgba(30,30,40,0.9); border: 1px solid rgba(100,100,120,0.3);
color: #e0e0e0; padding: 10px 20px; border-radius: 8px; font-size: 14px;
cursor: pointer;
}
button:hover, select:hover { background: rgba(50,50,70,0.9); }
.drop-zone {
position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; pointer-events: none; z-index: 5;
}
.drop-zone.active {
background: rgba(100,50,150,0.2);
border: 3px dashed rgba(150,100,200,0.5);
}
.drop-text {
color: rgba(255,255,255,0.3); font-size: 24px; font-family: sans-serif;
}
</style>
</head>
<body>
<div class="title">Visualizer</div>
<div class="drop-zone" id="drop-zone">
<span class="drop-text">Drop WAV file here or use controls</span>
</div>
<canvas id="canvas"></canvas>
<div class="controls">
<input type="file" id="file-input" accept="audio/*" style="display: none;">
<button id="load-btn">Load Audio</button>
<button id="play-btn" disabled>Play</button>
<button id="stop-btn" disabled>Stop</button>
<select id="viz-mode">
<option value="spectrum">Spectrum</option>
<option value="waveform">Waveform</option>
<option value="spectrogram">Spectrogram</option>
<option value="circular">Circular</option>
</select>
</div>
<script>
class AudioVisualizer {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.source = null;
this.audioBuffer = null;
this.isPlaying = false;
this.mode = 'spectrum';
this.spectrogramData = [];
this.startOffset = 0;
this.resize();
window.addEventListener('resize', () => this.resize());
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.8;
this.analyser.connect(this.audioCtx.destination);
}
async loadAudio(file) {
await this.initAudio();
const arrayBuffer = await file.arrayBuffer();
this.audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
document.getElementById('play-btn').disabled = false;
document.getElementById('stop-btn').disabled = false;
document.querySelector('.drop-text').textContent = file.name;
}
play() {
if (!this.audioBuffer || this.isPlaying) return;
this.source = this.audioCtx.createBufferSource();
this.source.buffer = this.audioBuffer;
this.source.connect(this.analyser);
this.source.start(0, this.startOffset);
this.isPlaying = true;
this.source.onended = () => {
this.isPlaying = false;
this.startOffset = 0;
document.getElementById('play-btn').textContent = 'Play';
};
document.getElementById('play-btn').textContent = 'Pause';
}
stop() {
if (this.source && this.isPlaying) {
this.source.stop();
this.isPlaying = false;
this.startOffset = 0;
document.getElementById('play-btn').textContent = 'Play';
}
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.analyser) {
requestAnimationFrame(() => this.animate());
return;
}
const freqData = new Uint8Array(this.analyser.frequencyBinCount);
const timeData = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(freqData);
this.analyser.getByteTimeDomainData(timeData);
switch (this.mode) {
case 'spectrum': this.drawSpectrum(freqData); break;
case 'waveform': this.drawWaveform(timeData); break;
case 'spectrogram': this.drawSpectrogram(freqData); break;
case 'circular': this.drawCircular(freqData, timeData); break;
}
requestAnimationFrame(() => this.animate());
}
drawSpectrum(data) {
const w = this.canvas.width;
const h = this.canvas.height;
const barCount = 128;
const barWidth = w / barCount;
const step = Math.floor(data.length / barCount);
for (let i = 0; i < barCount; i++) {
const value = data[i * step] / 255;
const barHeight = value * h * 0.8;
const hue = (i / barCount) * 300;
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
this.ctx.fillRect(
i * barWidth,
h - barHeight,
barWidth - 1,
barHeight
);
// Mirror
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.2)`;
this.ctx.fillRect(
i * barWidth,
0,
barWidth - 1,
barHeight * 0.3
);
}
}
drawWaveform(data) {
const w = this.canvas.width;
const h = this.canvas.height;
this.ctx.beginPath();
this.ctx.strokeStyle = '#a78bfa';
this.ctx.lineWidth = 2;
const sliceWidth = w / data.length;
let x = 0;
for (let i = 0; i < data.length; i++) {
const v = data[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
x += sliceWidth;
}
this.ctx.stroke();
// Glow
this.ctx.strokeStyle = 'rgba(167,139,250,0.3)';
this.ctx.lineWidth = 6;
this.ctx.stroke();
}
drawSpectrogram(data) {
const w = this.canvas.width;
const h = this.canvas.height;
// Add new line
this.spectrogramData.push([...data]);
// Keep last N lines
const maxLines = Math.floor(w / 2);
if (this.spectrogramData.length > maxLines) {
this.spectrogramData.shift();
}
// Draw
const line_width = w / maxLines;
this.spectrogramData.forEach((line, x) => {
const step = Math.floor(line.length / h);
for (let y = 0; y < h; y++) {
const value = line[y * step] || 0;
const hue = 270 - (value / 255) * 200;
const lightness = 20 + (value / 255) * 50;
this.ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
this.ctx.fillRect(x * line_width, h - y, line_width, 1);
}
});
}
drawCircular(freqData, timeData) {
const w = this.canvas.width;
const h = this.canvas.height;
const cx = w / 2;
const cy = h / 2;
const maxRadius = Math.min(w, h) * 0.35;
// Frequency ring
const freqStep = Math.floor(freqData.length / 180);
for (let i = 0; i < 180; i++) {
const value = freqData[i * freqStep] / 255;
const angle = (i / 180) * Math.PI * 2;
const radius = maxRadius * 0.5 + value * maxRadius * 0.5;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
this.ctx.beginPath();
this.ctx.arc(x, y, 3, 0, Math.PI * 2);
const hue = (i / 180) * 360;
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
this.ctx.fill();
}
// Waveform circle
this.ctx.beginPath();
this.ctx.strokeStyle = 'rgba(255,255,255,0.3)';
this.ctx.lineWidth = 1;
for (let i = 0; i < timeData.length; i++) {
const v = timeData[i] / 128.0;
const angle = (i / timeData.length) * Math.PI * 2;
const radius = maxRadius * 0.3 * v;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
}
this.ctx.closePath();
this.ctx.stroke();
// Center glow
const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, maxRadius * 0.3);
gradient.addColorStop(0, 'rgba(167,139,250,0.3)');
gradient.addColorStop(1, 'rgba(167,139,250,0)');
this.ctx.beginPath();
this.ctx.arc(cx, cy, maxRadius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
}
bindEvents() {
document.getElementById('load-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', async (e) => {
if (e.target.files[0]) {
await this.loadAudio(e.target.files[0]);
}
});
document.getElementById('play-btn').addEventListener('click', () => {
if (this.isPlaying) {
this.stop();
} else {
this.play();
}
});
document.getElementById('stop-btn').addEventListener('click', () => {
this.stop();
});
document.getElementById('viz-mode').addEventListener('change', (e) => {
this.mode = e.target.value;
this.spectrogramData = [];
});
// Drag and drop
const dropZone = document.getElementById('drop-zone');
document.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('active');
});
document.addEventListener('dragleave', () => {
dropZone.classList.remove('active');
});
document.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) {
await this.loadAudio(file);
}
});
// Touch support
this.canvas.addEventListener('click', async () => {
if (!this.audioCtx) {
await this.initAudio();
}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
});
}
}
new AudioVisualizer();
</script>
</body>
</html>

View File

@@ -1,377 +0,0 @@
"""
Tests for agent memory — cross-session agent memory via MemPalace.
Tests the memory module, hooks, and session mining without requiring
a live ChromaDB instance. Uses mocking for the MemPalace backend.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from agent.memory import (
AgentMemory,
MemoryContext,
SessionTranscript,
create_agent_memory,
)
from agent.memory_hooks import MemoryHooks
# ---------------------------------------------------------------------------
# SessionTranscript tests
# ---------------------------------------------------------------------------
class TestSessionTranscript:
def test_create(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
assert t.agent_name == "test"
assert t.wing == "wing_test"
assert len(t.entries) == 0
def test_add_user_turn(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
t.add_user_turn("Hello")
assert len(t.entries) == 1
assert t.entries[0]["role"] == "user"
assert t.entries[0]["text"] == "Hello"
def test_add_agent_turn(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
t.add_agent_turn("Response")
assert t.entries[0]["role"] == "agent"
def test_add_tool_call(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
t.add_tool_call("shell", "ls", "file1 file2")
assert t.entries[0]["role"] == "tool"
assert t.entries[0]["tool"] == "shell"
def test_summary_empty(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
assert t.summary() == "Empty session."
def test_summary_with_entries(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
t.add_user_turn("Do something")
t.add_agent_turn("Done")
t.add_tool_call("shell", "ls", "ok")
summary = t.summary()
assert "USER: Do something" in summary
assert "AGENT: Done" in summary
assert "TOOL(shell): ok" in summary
def test_text_truncation(self):
t = SessionTranscript(agent_name="test", wing="wing_test")
long_text = "x" * 5000
t.add_user_turn(long_text)
assert len(t.entries[0]["text"]) == 2000
# ---------------------------------------------------------------------------
# MemoryContext tests
# ---------------------------------------------------------------------------
class TestMemoryContext:
def test_empty_context(self):
ctx = MemoryContext()
assert ctx.to_prompt_block() == ""
def test_unloaded_context(self):
ctx = MemoryContext()
ctx.loaded = False
assert ctx.to_prompt_block() == ""
def test_loaded_with_data(self):
ctx = MemoryContext()
ctx.loaded = True
ctx.recent_diaries = [
{"text": "Fixed PR #1386", "timestamp": "2026-04-13T10:00:00Z"}
]
ctx.facts = [
{"text": "Bezalel runs on VPS Beta", "score": 0.95}
]
ctx.relevant_memories = [
{"text": "Changed CI runner", "score": 0.87}
]
block = ctx.to_prompt_block()
assert "Recent Session Summaries" in block
assert "Fixed PR #1386" in block
assert "Known Facts" in block
assert "Bezalel runs on VPS Beta" in block
assert "Relevant Past Memories" in block
def test_loaded_empty(self):
ctx = MemoryContext()
ctx.loaded = True
# No data — should return empty string
assert ctx.to_prompt_block() == ""
# ---------------------------------------------------------------------------
# AgentMemory tests (with mocked MemPalace)
# ---------------------------------------------------------------------------
class TestAgentMemory:
def test_create(self):
mem = AgentMemory(agent_name="bezalel")
assert mem.agent_name == "bezalel"
assert mem.wing == "wing_bezalel"
def test_custom_wing(self):
mem = AgentMemory(agent_name="bezalel", wing="custom_wing")
assert mem.wing == "custom_wing"
def test_factory(self):
mem = create_agent_memory("ezra")
assert mem.agent_name == "ezra"
assert mem.wing == "wing_ezra"
def test_unavailable_graceful(self):
"""Test graceful degradation when MemPalace is unavailable."""
mem = AgentMemory(agent_name="test")
mem._available = False # Force unavailable
# Should not raise
ctx = mem.recall_context("test query")
assert ctx.loaded is False
assert ctx.error == "MemPalace unavailable"
# remember returns None
assert mem.remember("test") is None
# search returns empty
assert mem.search("test") == []
def test_start_end_session(self):
mem = AgentMemory(agent_name="test")
mem._available = False
transcript = mem.start_session()
assert isinstance(transcript, SessionTranscript)
assert mem._transcript is not None
doc_id = mem.end_session()
assert mem._transcript is None
def test_remember_graceful_when_unavailable(self):
"""Test remember returns None when MemPalace is unavailable."""
mem = AgentMemory(agent_name="test")
mem._available = False
doc_id = mem.remember("some important fact")
assert doc_id is None
def test_write_diary_from_transcript(self):
mem = AgentMemory(agent_name="test")
mem._available = False
transcript = mem.start_session()
transcript.add_user_turn("Hello")
transcript.add_agent_turn("Hi there")
# Write diary should handle unavailable gracefully
doc_id = mem.write_diary()
assert doc_id is None # MemPalace unavailable
# ---------------------------------------------------------------------------
# MemoryHooks tests
# ---------------------------------------------------------------------------
class TestMemoryHooks:
def test_create(self):
hooks = MemoryHooks(agent_name="bezalel")
assert hooks.agent_name == "bezalel"
assert hooks.is_active is False
def test_session_lifecycle(self):
hooks = MemoryHooks(agent_name="test")
# Force memory unavailable
hooks._memory = AgentMemory(agent_name="test")
hooks._memory._available = False
# Start session
block = hooks.on_session_start()
assert hooks.is_active is True
assert block == "" # No memory available
# Record turns
hooks.on_user_turn("Hello")
hooks.on_agent_turn("Hi")
hooks.on_tool_call("shell", "ls", "ok")
# Record decision
hooks.on_important_decision("Switched to self-hosted CI")
# End session
doc_id = hooks.on_session_end()
assert hooks.is_active is False
def test_hooks_before_session(self):
"""Hooks before session start should be no-ops."""
hooks = MemoryHooks(agent_name="test")
hooks._memory = AgentMemory(agent_name="test")
hooks._memory._available = False
# Should not raise
hooks.on_user_turn("Hello")
hooks.on_agent_turn("Response")
def test_hooks_after_session_end(self):
"""Hooks after session end should be no-ops."""
hooks = MemoryHooks(agent_name="test")
hooks._memory = AgentMemory(agent_name="test")
hooks._memory._available = False
hooks.on_session_start()
hooks.on_session_end()
# Should not raise
hooks.on_user_turn("Late message")
doc_id = hooks.on_session_end()
assert doc_id is None
def test_search_during_session(self):
hooks = MemoryHooks(agent_name="test")
hooks._memory = AgentMemory(agent_name="test")
hooks._memory._available = False
results = hooks.search("some query")
assert results == []
# ---------------------------------------------------------------------------
# Session mining tests
# ---------------------------------------------------------------------------
class TestSessionMining:
def test_parse_session_file(self):
from bin.memory_mine import parse_session_file
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write('{"role": "user", "content": "Hello"}\n')
f.write('{"role": "assistant", "content": "Hi there"}\n')
f.write('{"role": "tool", "name": "shell", "content": "ls output"}\n')
f.write("\n") # blank line
f.write("not json\n") # malformed
path = Path(f.name)
turns = parse_session_file(path)
assert len(turns) == 3
assert turns[0]["role"] == "user"
assert turns[1]["role"] == "assistant"
assert turns[2]["role"] == "tool"
path.unlink()
def test_summarize_session(self):
from bin.memory_mine import summarize_session
turns = [
{"role": "user", "content": "Check CI"},
{"role": "assistant", "content": "Running CI check..."},
{"role": "tool", "name": "shell", "content": "5 tests passed"},
{"role": "assistant", "content": "CI is healthy"},
]
summary = summarize_session(turns, "bezalel")
assert "bezalel" in summary
assert "Check CI" in summary
assert "shell" in summary
def test_summarize_empty(self):
from bin.memory_mine import summarize_session
assert summarize_session([], "test") == "Empty session."
def test_find_session_files(self, tmp_path):
from bin.memory_mine import find_session_files
# Create some test files
(tmp_path / "session1.jsonl").write_text("{}\n")
(tmp_path / "session2.jsonl").write_text("{}\n")
(tmp_path / "notes.txt").write_text("not a session")
files = find_session_files(tmp_path, days=365)
assert len(files) == 2
assert all(f.suffix == ".jsonl" for f in files)
def test_find_session_files_missing_dir(self):
from bin.memory_mine import find_session_files
files = find_session_files(Path("/nonexistent/path"), days=7)
assert files == []
def test_mine_session_dry_run(self, tmp_path):
from bin.memory_mine import mine_session
session_file = tmp_path / "test.jsonl"
session_file.write_text(
'{"role": "user", "content": "Hello"}\n'
'{"role": "assistant", "content": "Hi"}\n'
)
result = mine_session(session_file, wing="wing_test", dry_run=True)
assert result is None # dry run doesn't store
def test_mine_session_empty_file(self, tmp_path):
from bin.memory_mine import mine_session
session_file = tmp_path / "empty.jsonl"
session_file.write_text("")
result = mine_session(session_file, wing="wing_test")
assert result is None
# ---------------------------------------------------------------------------
# Integration test — full lifecycle
# ---------------------------------------------------------------------------
class TestFullLifecycle:
"""Test the full session lifecycle without a real MemPalace backend."""
def test_full_session_flow(self):
hooks = MemoryHooks(agent_name="bezalel")
# Force memory unavailable
hooks._memory = AgentMemory(agent_name="bezalel")
hooks._memory._available = False
# 1. Session start
context_block = hooks.on_session_start("What CI issues do I have?")
assert isinstance(context_block, str)
# 2. User asks question
hooks.on_user_turn("Check CI pipeline health")
# 3. Agent uses tool
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
# 4. Agent responds
hooks.on_agent_turn("CI pipeline is healthy. All 12 tests passing.")
# 5. Important decision
hooks.on_important_decision("Decided to keep current CI runner", room="forge")
# 6. More interaction
hooks.on_user_turn("Good, check memory integration next")
hooks.on_agent_turn("Will test agent.memory module")
# 7. Session end
doc_id = hooks.on_session_end()
assert hooks.is_active is False