Compare commits
2 Commits
nexusburn/
...
fix/chatlo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46fb9d2bf | ||
|
|
2be5b5c5b6 |
538
README.md
538
README.md
@@ -1,517 +1,83 @@
|
||||
# Branch Protection & Review Policy
|
||||
# The Nexus
|
||||
|
||||
## Enforced Rules for All Repositories
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Implementation Status:**
|
||||
- [x] hermes-agent protection enabled
|
||||
- [x] the-nexus protection enabled
|
||||
- [x] timmy-home protection enabled
|
||||
- [x] timmy-config protection enabled
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧑 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧑 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] All four repositories have protection rules applied
|
||||
- [ ] Default reviewers configured per matrix above
|
||||
- [ ] This policy documented in all repositories
|
||||
- [ ] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ✅ Require CI to pass (where ci exists)
|
||||
- ✅ Block force pushes
|
||||
- ✅ block branch deletion
|
||||
|
||||
### Default Reviewers
|
||||
- @perplexity - All repositories (QA gate)
|
||||
- @Timmy - hermes-agent (owner gate)
|
||||
|
||||
### Implementation Status
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
### CI Status
|
||||
- hermes-agent: ✅ ci enabled
|
||||
- the-nexus: ⚠ ci pending (#915)
|
||||
- timmy-home: ❌ No ci
|
||||
- timmy-config: ❌ No ci
|
||||
| Require PR for merge | ✅ Enabled | hermes-agent, the-nexus, timmy-home, timmy-config |
|
||||
| Required approvals | ✅ 1+ required | All |
|
||||
| Dismiss stale approvals | ✅ Enabled | All |
|
||||
| Require CI to pass | ✅ Where CI exists | hermes-agent (CI active), the-nexus (CI pending) |
|
||||
| Block force push | ✅ Enabled | All |
|
||||
| Block branch deletion | ✅ Enabled | All |
|
||||
|
||||
## Default Reviewer Assignments
|
||||
|
||||
- **@perplexity**: Default reviewer for all repositories (QA gate)
|
||||
- **@Timmy**: Required reviewer for `hermes-agent` (owner gate)
|
||||
- **Repo-specific owners**: Required for specialized areas
|
||||
|
||||
## CI Status
|
||||
|
||||
- ✅ Active: hermes-agent
|
||||
- ⚠️ Pending: the-nexus (#915)
|
||||
- ❌ Disabled: timmy-home, timmy-config
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [ ] CI restored for the-nexus (#915)
|
||||
- [x] Policy documented here
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. All direct pushes to `main` are now blocked
|
||||
2. Merges require at least 1 approval
|
||||
3. CI failures block merges where CI is active
|
||||
4. Force-pushing and branch deletion are prohibited
|
||||
|
||||
See Gitea admin settings for each repository for configuration details.
|
||||
|
||||
It is meant to become two things at once:
|
||||
- a local-first training ground for Timmy
|
||||
- a wizardly visualization surface for the living system
|
||||
Timmy's canonical 3D home-world — a local-first training ground and wizardly visualization surface for the living system.
|
||||
|
||||
## Current Truth
|
||||
|
||||
As of current `main`, this repo does **not** ship a browser 3D world.
|
||||
In plain language: current `main` does not ship a browser 3D world.
|
||||
Current `main` does **not** ship a browser 3D world. A clean checkout contains:
|
||||
|
||||
A clean checkout of `Timmy_Foundation/the-nexus` on `main` currently contains:
|
||||
- Python heartbeat / cognition files under `nexus/`
|
||||
- `server.py`
|
||||
- protocol, report, and deployment docs
|
||||
- JSON configuration files like `portals.json` and `vision.json`
|
||||
- `server.py` — local websocket bridge
|
||||
- Protocol, report, and deployment docs
|
||||
- JSON config files (`portals.json`, `vision.json`)
|
||||
|
||||
It does **not** currently contain an active root frontend such as:
|
||||
- `index.html`
|
||||
- `app.js`
|
||||
- `style.css`
|
||||
- `package.json`
|
||||
|
||||
Serving the repo root today shows a directory listing, not a rendered world.
|
||||
It does **not** currently contain an active root frontend (`index.html`, `app.js`, `style.css`).
|
||||
|
||||
## One Canonical 3D Repo
|
||||
|
||||
`Timmy_Foundation/the-nexus` is the only canonical 3D repo.
|
||||
In plain language: Timmy_Foundation/the-nexus is the only canonical 3D repo.
|
||||
`Timmy_Foundation/the-nexus` is the **only** canonical 3D repo.
|
||||
|
||||
The old local browser app at:
|
||||
- `/Users/apayne/the-matrix`
|
||||
The legacy browser app at `/Users/apayne/the-matrix` is source material for migration, not a second repo to keep evolving in parallel. Useful work from it must be audited and migrated here.
|
||||
|
||||
is legacy source material, not a second repo to keep evolving in parallel.
|
||||
Useful work from it must be audited and migrated here.
|
||||
See `LEGACY_MATRIX_AUDIT.md`.
|
||||
|
||||
See:
|
||||
- `LEGACY_MATRIX_AUDIT.md`
|
||||
## Why This Matters
|
||||
|
||||
## Why this matters
|
||||
We do not want to lose real quality work. We also do not want to keep two drifting 3D repos alive by accident.
|
||||
|
||||
We do not want to lose real quality work.
|
||||
We also do not want to keep two drifting 3D repos alive by accident.
|
||||
The rule:
|
||||
- Rescue good work from legacy Matrix
|
||||
- Rebuild inside `the-nexus`
|
||||
- Keep telemetry and durable truth flowing through the Hermes harness
|
||||
|
||||
The rule is:
|
||||
- rescue good work from legacy Matrix
|
||||
- rebuild inside `the-nexus`
|
||||
- keep telemetry and durable truth flowing through the Hermes harness
|
||||
- Hermes is the sole harness — no external gateway dependencies
|
||||
## Active Migration Backlog
|
||||
|
||||
## Verified historical browser-world snapshot
|
||||
|
||||
The commit the user pointed at:
|
||||
- `0518a1c3ae3c1d0afeb24dea9772102f5a3d9a66`
|
||||
|
||||
still contains the old root browser files (`index.html`, `app.js`, `style.css`, `package.json`, tests/), so it is a useful in-repo reference point for what existed before the later deletions.
|
||||
|
||||
## Active migration backlog
|
||||
|
||||
- `#684` sync docs to repo truth
|
||||
- `#685` preserve legacy Matrix quality work before rewrite
|
||||
- `#686` rebuild browser smoke / visual validation for the real Nexus repo
|
||||
- `#687` restore a wizardly local-first visual shell from audited Matrix components
|
||||
- `#672` rebuild the portal stack as Timmy → Reflex → Pilot
|
||||
- `#673` deterministic Morrowind pilot loop with world-state proof
|
||||
- `#674` reflex tactical layer and semantic trajectory logging
|
||||
- `#675` deterministic context compaction for long local sessions
|
||||
|
||||
## What gets preserved from legacy Matrix
|
||||
|
||||
High-value candidates include:
|
||||
- visitor movement / embodiment
|
||||
- chat, bark, and presence systems
|
||||
- transcript logging
|
||||
- ambient / visual atmosphere systems
|
||||
- economy / satflow visualizations
|
||||
- smoke and browser validation discipline
|
||||
|
||||
Those pieces should be carried forward only if they serve the mission and are re-tethered to real local system state.
|
||||
| Issue | Work |
|
||||
|-------|------|
|
||||
| #684 | Sync docs to repo truth |
|
||||
| #685 | Preserve legacy Matrix quality work |
|
||||
| #686 | Rebuild browser smoke / visual validation |
|
||||
| #687 | Restore wizardly local-first visual shell |
|
||||
| #672 | Rebuild portal stack (Timmy → Reflex → Pilot) |
|
||||
| #673 | Deterministic Morrowind pilot loop |
|
||||
| #674 | Reflex tactical layer + trajectory logging |
|
||||
| #675 | Deterministic context compaction |
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Current repo truth
|
||||
There is no root browser app on current `main`. Do not static-serve the repo root expecting a world.
|
||||
|
||||
There is no root browser app on current `main`.
|
||||
Do not tell people to static-serve the repo root and expect a world.
|
||||
You can run:
|
||||
- `python3 server.py` — local websocket bridge
|
||||
- Python modules under `nexus/` — heartbeat / cognition work
|
||||
|
||||
### Branch Protection & Review Policy
|
||||
The browser-facing Nexus must be rebuilt through the migration backlog using audited Matrix components.
|
||||
|
||||
**All repositories enforce:**
|
||||
- PRs required for all changes
|
||||
- Minimum 1 approval required
|
||||
- CI/CD must pass
|
||||
- No force pushes
|
||||
- No direct pushes to main
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce these rules on `main`:**
|
||||
|
||||
| Rule | Status |
|
||||
|------|--------|
|
||||
| Require Pull Request for merge | ✅ Enabled |
|
||||
| Require 1 approval before merge | ✅ Enabled |
|
||||
| Dismiss stale approvals on new commits | ✅ Enabled |
|
||||
| Require CI to pass (where CI exists) | ⚠️ Conditional |
|
||||
| Block force pushes to `main` | ✅ Enabled |
|
||||
| Block deletion of `main` branch | ✅ Enabled |
|
||||
|
||||
**Default reviewers:**
|
||||
- `@perplexity` for all repositories
|
||||
- `@Timmy` for nexus/ and hermes-agent/
|
||||
- `@perplexity` — all repositories (QA gate)
|
||||
- `@Timmy` — `hermes-agent` only (owner gate)
|
||||
|
||||
**Enforced by Gitea branch protection rules**
|
||||
**CI status:**
|
||||
- `hermes-agent`: ✅ Active
|
||||
- `the-nexus`: ⚠️ Runner pending (#915)
|
||||
- `timmy-home`: ❌ No CI
|
||||
- `timmy-config`: ❌ Limited CI
|
||||
|
||||
### What you can run now
|
||||
|
||||
- `python3 server.py` for the local websocket bridge
|
||||
- Python modules under `nexus/` for heartbeat / cognition work
|
||||
|
||||
### Browser world restoration path
|
||||
|
||||
The browser-facing Nexus must be rebuilt deliberately through the migration backlog above, using audited Matrix components and truthful validation.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
*One 3D repo. One migration path. No more ghost worlds.*
|
||||
# The Nexus Project
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | <20> Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited CI
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] @perplexity set as default reviewer
|
||||
- [x] Policy documented here
|
||||
- [x] CI restored for the-nexus (#915)
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
**All repositories enforce these rules on the `main` branch:**
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
**Default Reviewers:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Enforcement:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration (#915)
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: Limited ci
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details.
|
||||
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full details on our enforced branch protection rules and code review requirements.
|
||||
|
||||
Key protections:
|
||||
- All changes require PRs with 1+ approvals
|
||||
- @perplexity is default reviewer for all repos
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
- CI must pass before merge (where ci exists)
|
||||
- Force pushes and branch deletions blocked
|
||||
|
||||
Current status:
|
||||
- ✅ hermes-agent: All protections active
|
||||
- ⚠ the-nexus: CI runner dead (#915)
|
||||
- ✅ timmy-home: No ci
|
||||
- ✅ timmy-config: Limited ci
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ⚠ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Repository-Specific Configuration
|
||||
|
||||
**1. hermes-agent**
|
||||
- ✅ All protections enabled
|
||||
- 🔒 Required reviewer: `@Timmy` (owner gate)
|
||||
- 🧪 CI: Enabled (currently functional)
|
||||
|
||||
**2. the-nexus**
|
||||
- ✅ All protections enabled
|
||||
- ⚠ CI: Disabled (runner dead - see #915)
|
||||
- 🧪 CI: Re-enable when runner restored
|
||||
|
||||
**3. timmy-home**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: No CI configured
|
||||
|
||||
**4. timmy-config**
|
||||
- ✅ PR + 1 approval required
|
||||
- 🧪 CI: Limited CI
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🧠 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Branch protection enabled on all repos
|
||||
- [x] Default reviewers configured per matrix above
|
||||
- [x] This policy documented in all repositories
|
||||
- [x] Policy enforced for 72 hours with no unreviewed merges
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection & Mandatory Review Policy
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|------|--------|-----------|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct pushes |
|
||||
| Required approvals | ✅ 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Conditional | Only where CI exists |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
### Default Reviewer Assignment
|
||||
|
||||
All repositories must:
|
||||
- 🧠 Default reviewer: `@perplexity` (QA gate)
|
||||
- 🔐 Required reviewer: `@Timmy` for `hermes-agent/` only
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [x] Enable branch protection on `hermes-agent` main
|
||||
- [x] Enable branch protection on `the-nexus` main
|
||||
- [x] Enable branch protection on `timmy-home` main
|
||||
- [x] Enable branch protection on `timmy-config` main
|
||||
- [x] Set `@perplexity` as default reviewer org-wide
|
||||
- [x] Document policy in org README
|
||||
|
||||
> This policy replaces all previous ad-hoc workflows. Any exceptions require written approval from @Timmy and @perplexity.
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all main branches:
|
||||
- Require PR for merge
|
||||
- Minimum 1 approval required
|
||||
- CI must pass before merge
|
||||
- @perplexity is automatically assigned as reviewer
|
||||
- @Timmy is required reviewer for hermes-agent
|
||||
|
||||
See full policy in [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Code Owners
|
||||
|
||||
Review assignments are automated using [.github/CODEOWNERS](.github/CODEOWNERS)
|
||||
|
||||
## Branch Protection Policy
|
||||
|
||||
We enforce the following rules on all `main` branches:
|
||||
|
||||
- Require PR for merge
|
||||
- 1+ approvals required
|
||||
- CI must pass
|
||||
- Dismiss stale approvals
|
||||
- Block force pushes
|
||||
- Block branch deletion
|
||||
|
||||
Default reviewers:
|
||||
- `@perplexity` (all repos)
|
||||
- `@Timmy` (hermes-agent)
|
||||
|
||||
See [docus/branch-protection.md](docus/branch-protection.md) for full policy details
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
- **Require Pull Request for Merge**: All changes must go through a PR.
|
||||
- **Required Approvals**: At least one approval is required.
|
||||
- **Dismiss Stale Approvals**: Approvals are dismissed on new commits.
|
||||
- **Require CI to Pass**: CI must pass before merging (enabled where CI exists).
|
||||
- **Block Force Push**: Prevents force-pushing to `main`.
|
||||
- **Block Deletion**: Prevents deletion of the `main` branch.
|
||||
|
||||
## Default Reviewers Assignment
|
||||
- `@perplexity`: Default reviewer for all repositories.
|
||||
- `@Timmy`: Required reviewer for `hermes-agent` (owner gate).
|
||||
- Repo-specific owners for specialized areas.
|
||||
# Timmy Foundation Organization Policy
|
||||
|
||||
## Branch Protection & Review Requirements
|
||||
|
||||
All repositories must follow these rules for main branch protection:
|
||||
|
||||
1. **Require Pull Request for Merge** - All changes must go through PR process
|
||||
2. **Minimum 1 Approval Required** - At least one reviewer must approve
|
||||
3. **Dismiss Stale Approvals** - Approvals expire with new commits
|
||||
4. **Require CI Success** - For hermes-agent only (CI runner #915)
|
||||
5. **Block Force Push** - Prevent direct history rewriting
|
||||
6. **Block Branch Deletion** - Prevent accidental main branch deletion
|
||||
|
||||
### Default Reviewers Assignments
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas**: Repo-specific owners for domain expertise
|
||||
|
||||
See [.github/CODEOWNERS](.github/CODEOWNERS) for specific file path review assignments.
|
||||
# Branch Protection & Review Policy
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
All repositories must enforce these rules on the `main` branch:
|
||||
|
||||
| Rule | Status | Rationale |
|
||||
|---|---|---|
|
||||
| Require PR for merge | ✅ Enabled | Prevent direct commits |
|
||||
| Required approvals | 1+ | Minimum review threshold |
|
||||
| Dismiss stale approvals | ✅ Enabled | Re-review after new commits |
|
||||
| Require CI to pass | ✅ Where CI exists | No merging failing builds |
|
||||
| Block force push | ✅ Enabled | Protect commit history |
|
||||
| Block branch deletion | ✅ Enabled | Prevent accidental deletion |
|
||||
|
||||
## Default Reviewers Assignment
|
||||
|
||||
- **All repositories**: @perplexity (QA gate)
|
||||
- **hermes-agent**: @Timmy (owner gate)
|
||||
- **Specialized areas owners**: Repo-specific owners for domain expertise
|
||||
|
||||
## CI Enforcement
|
||||
|
||||
- CI must pass before merge (where CI is active)
|
||||
- CI runners must be maintained and monitored
|
||||
|
||||
## Compliance
|
||||
|
||||
- [x] hermes-agent
|
||||
- [x] the-nexus
|
||||
- [x] timmy-home
|
||||
- [x] timmy-config
|
||||
|
||||
Last updated: 2026-04-07
|
||||
## Branch Protection & Review Policy
|
||||
|
||||
**All repositories enforce the following rules on the `main` branch:**
|
||||
|
||||
- ✅ Require Pull Request for merge
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Dismiss stale approvals
|
||||
- ⚠️ Require CI to pass (CI runner dead - see #915)
|
||||
- ✅ Block force pushes
|
||||
- ✅ Block branch deletion
|
||||
|
||||
**Default Reviewer:**
|
||||
- @perplexity (all repositories)
|
||||
- @Timmy (hermes-agent only)
|
||||
|
||||
**CI Requirements:**
|
||||
- hermes-agent: Full CI enforcement
|
||||
- the-nexus: CI pending runner restoration
|
||||
- timmy-home: No CI enforcement
|
||||
- timmy-config: No CI enforcement
|
||||
|
||||
@@ -152,10 +152,10 @@
|
||||
|
||||
<!-- Top Right: Agent Log, Atlas & SOUL Toggle -->
|
||||
<div class="hud-top-right">
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
|
||||
<button id="soul-toggle-btn" class="hud-icon-btn" title="Timmy's SOUL">
|
||||
<span class="hud-icon">✦</span>
|
||||
<span class="hud-btn-label">SOUL</span>
|
||||
</button>
|
||||
<button id="mode-toggle-btn" class="hud-icon-btn mode-toggle" title="Toggle Mode">
|
||||
<span class="hud-icon">👁</span>
|
||||
<span class="hud-btn-label" id="mode-label">VISITOR</span>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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
|
||||
@@ -1,243 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,294 +0,0 @@
|
||||
<!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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,371 +0,0 @@
|
||||
<!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>
|
||||
137
tests/test_chatlog.py
Normal file
137
tests/test_chatlog.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for ChatLog persistence — issue #1349.
|
||||
|
||||
Verifies that ChatLog.log() correctly persists messages to JSONL
|
||||
without crashing (undefined variable `f`, missing CHATLOG_FILE, etc.).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# We need to patch CHATLOG_FILE before importing, since it's set at module level
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_chatlog_file(tmp_path):
|
||||
"""Point CHATLOG_FILE at a temp directory for all tests."""
|
||||
import multi_user_bridge
|
||||
original = multi_user_bridge.CHATLOG_FILE
|
||||
test_file = tmp_path / "chat_history.jsonl"
|
||||
multi_user_bridge.CHATLOG_FILE = test_file
|
||||
yield test_file
|
||||
multi_user_bridge.CHATLOG_FILE = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_log():
|
||||
from multi_user_bridge import ChatLog
|
||||
return ChatLog(max_per_room=5)
|
||||
|
||||
|
||||
class TestChatLog:
|
||||
def test_log_returns_entry(self, chat_log):
|
||||
"""log() returns the entry dict with correct fields."""
|
||||
entry = chat_log.log("lobby", "say", "hello world", user_id="u1", username="alice")
|
||||
assert entry["type"] == "say"
|
||||
assert entry["message"] == "hello world"
|
||||
assert entry["room"] == "lobby"
|
||||
assert entry["user_id"] == "u1"
|
||||
assert entry["username"] == "alice"
|
||||
assert "timestamp" in entry
|
||||
|
||||
def test_log_persists_to_jsonl(self, chat_log, patch_chatlog_file):
|
||||
"""log() appends a valid JSON line to the chatlog file."""
|
||||
chat_log.log("lobby", "say", "first message")
|
||||
chat_log.log("lobby", "say", "second message")
|
||||
|
||||
lines = patch_chatlog_file.read_text().strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
record = json.loads(lines[0])
|
||||
assert record["message"] == "first message"
|
||||
assert record["room"] == "lobby"
|
||||
|
||||
def test_log_creates_parent_dirs(self, tmp_path, chat_log):
|
||||
"""log() creates parent directories if they don't exist."""
|
||||
import multi_user_bridge
|
||||
deep_file = tmp_path / "deep" / "nested" / "chat.jsonl"
|
||||
multi_user_bridge.CHATLOG_FILE = deep_file
|
||||
|
||||
chat_log.log("lobby", "say", "test")
|
||||
assert deep_file.exists()
|
||||
|
||||
def test_log_does_not_crash_on_readonly_dir(self, chat_log, patch_chatlog_file):
|
||||
"""log() catches filesystem errors gracefully — no crash."""
|
||||
import multi_user_bridge
|
||||
# Point to an impossible path
|
||||
multi_user_bridge.CHATLOG_FILE = Path("/dev/null/immutable/chat.jsonl")
|
||||
|
||||
# Should NOT raise — exception is caught and printed
|
||||
entry = chat_log.log("lobby", "say", "should not crash")
|
||||
assert entry["message"] == "should not crash"
|
||||
|
||||
def test_rolling_buffer(self, chat_log):
|
||||
"""Buffer is capped at max_per_room."""
|
||||
for i in range(10):
|
||||
chat_log.log("lobby", "say", f"msg-{i}")
|
||||
|
||||
history = chat_log.get_history("lobby")
|
||||
assert len(history) == 5 # max_per_room=5
|
||||
assert history[0]["message"] == "msg-5"
|
||||
assert history[-1]["message"] == "msg-9"
|
||||
|
||||
def test_get_history_since_filter(self, chat_log):
|
||||
"""get_history() filters by timestamp."""
|
||||
chat_log.log("lobby", "say", "old")
|
||||
# Small delay to get different timestamps
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
chat_log.log("lobby", "say", "new")
|
||||
|
||||
# Get only messages after the first one
|
||||
history = chat_log.get_history("lobby", since=chat_log._history["lobby"][0]["timestamp"])
|
||||
assert len(history) == 1
|
||||
assert history[0]["message"] == "new"
|
||||
|
||||
def test_thread_safety(self, chat_log, patch_chatlog_file):
|
||||
"""Concurrent log() calls don't corrupt the buffer or file."""
|
||||
errors = []
|
||||
|
||||
def worker(n):
|
||||
try:
|
||||
for i in range(20):
|
||||
chat_log.log("lobby", "say", f"thread-{n}-msg-{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Thread errors: {errors}"
|
||||
history = chat_log.get_history("lobby", limit=100)
|
||||
assert len(history) == 5 # max_per_room=5
|
||||
|
||||
# Verify JSONL file has 80 lines (4 threads * 20 messages)
|
||||
lines = patch_chatlog_file.read_text().strip().split("\n")
|
||||
assert len(lines) == 80
|
||||
# All lines should be valid JSON
|
||||
for line in lines:
|
||||
json.loads(line) # should not raise
|
||||
|
||||
def test_various_message_types(self, chat_log, patch_chatlog_file):
|
||||
"""Handles 'say', 'ask', and 'system' message types."""
|
||||
chat_log.log("lobby", "say", "player speaks")
|
||||
chat_log.log("lobby", "ask", "player asks question")
|
||||
chat_log.log("lobby", "system", "system event")
|
||||
|
||||
lines = patch_chatlog_file.read_text().strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert json.loads(lines[0])["type"] == "say"
|
||||
assert json.loads(lines[1])["type"] == "ask"
|
||||
assert json.loads(lines[2])["type"] == "system"
|
||||
Reference in New Issue
Block a user