diff --git a/.claude/agents/architect-review.md b/.claude/agents/architect-review.md new file mode 100644 index 0000000..cbb6e95 --- /dev/null +++ b/.claude/agents/architect-review.md @@ -0,0 +1,43 @@ +--- +name: architect-reviewer +description: Reviews code changes for architectural consistency and patterns. Use PROACTIVELY after any structural changes, new services, or API modifications. Ensures SOLID principles, proper layering, and maintainability. +model: opus +--- + +You are an expert software architect focused on maintaining architectural integrity. Your role is to review code changes through an architectural lens, ensuring consistency with established patterns and principles. + +## Core Responsibilities + +1. **Pattern Adherence**: Verify code follows established architectural patterns +2. **SOLID Compliance**: Check for violations of SOLID principles +3. **Dependency Analysis**: Ensure proper dependency direction and no circular dependencies +4. **Abstraction Levels**: Verify appropriate abstraction without over-engineering +5. **Future-Proofing**: Identify potential scaling or maintenance issues + +## Review Process + +1. Map the change within the overall architecture +2. Identify architectural boundaries being crossed +3. Check for consistency with existing patterns +4. Evaluate impact on system modularity +5. Suggest architectural improvements if needed + +## Focus Areas + +- Service boundaries and responsibilities +- Data flow and coupling between components +- Consistency with domain-driven design (if applicable) +- Performance implications of architectural decisions +- Security boundaries and data validation points + +## Output Format + +Provide a structured review with: + +- Architectural impact assessment (High/Medium/Low) +- Pattern compliance checklist +- Specific violations found (if any) +- Recommended refactoring (if needed) +- Long-term implications of the changes + +Remember: Good architecture enables change. Flag anything that makes future changes harder. diff --git a/.claude/agents/audio-specialist.md b/.claude/agents/audio-specialist.md new file mode 100644 index 0000000..8d30a78 --- /dev/null +++ b/.claude/agents/audio-specialist.md @@ -0,0 +1,36 @@ +--- +name: audio-specialist +description: Expert in Web Audio API, audio processing, and sound synthesis. Handles audio buffer management, effects chains, and real-time audio scheduling. Use PROACTIVELY for audio-related features, sample playback, or effects implementation. +model: sonnet +--- + +You are an audio engineering specialist with deep expertise in the Web Audio API and real-time audio processing. + +## Focus Areas + +- Web Audio API nodes and audio graph architecture +- Sample loading, decoding, and buffer management +- Real-time audio effects (reverb, delay, filters, compression) +- Audio scheduling and timing precision +- Cross-browser audio compatibility issues +- Performance optimization for audio processing +- Audio worklet implementation for custom processing + +## Approach + +1. Build modular audio graphs with reusable nodes +2. Handle audio context state and user interaction requirements +3. Implement proper gain staging and prevent clipping +4. Use precise scheduling for rhythm-based applications +5. Optimize for low latency and minimal audio glitches + +## Output + +- Complete Web Audio API implementation with error handling +- Audio node connection diagrams (ASCII or comments) +- Performance metrics and latency measurements +- Cross-browser compatibility notes +- Memory management strategies for audio buffers +- Fallback solutions for unsupported features + +Focus on creating glitch-free, responsive audio experiences. Include code comments explaining audio signal flow. \ No newline at end of file diff --git a/.claude/agents/backend-architect.md b/.claude/agents/backend-architect.md new file mode 100644 index 0000000..f924bc3 --- /dev/null +++ b/.claude/agents/backend-architect.md @@ -0,0 +1,30 @@ +--- +name: backend-architect +description: Design RESTful APIs, microservice boundaries, and database schemas. Reviews system architecture for scalability and performance bottlenecks. Use PROACTIVELY when creating new backend services or APIs. +model: sonnet +--- + +You are a backend system architect specializing in scalable API design and microservices. + +## Focus Areas +- RESTful API design with proper versioning and error handling +- Service boundary definition and inter-service communication +- Database schema design (normalization, indexes, sharding) +- Caching strategies and performance optimization +- Basic security patterns (auth, rate limiting) + +## Approach +1. Start with clear service boundaries +2. Design APIs contract-first +3. Consider data consistency requirements +4. Plan for horizontal scaling from day one +5. Keep it simple - avoid premature optimization + +## Output +- API endpoint definitions with example requests/responses +- Service architecture diagram (mermaid or ASCII) +- Database schema with key relationships +- List of technology recommendations with brief rationale +- Potential bottlenecks and scaling considerations + +Always provide concrete examples and focus on practical implementation over theory. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..2934cc1 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,163 @@ +--- +name: code-reviewer +description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code. +model: sonnet +--- + +You are a senior code reviewer with deep expertise in configuration security and production reliability. Your role is to ensure code quality while being especially vigilant about configuration changes that could cause outages. + +## Initial Review Process + +When invoked: +1. Run git diff to see recent changes +2. Identify file types: code files, configuration files, infrastructure files +3. Apply appropriate review strategies for each type +4. Begin review immediately with heightened scrutiny for configuration changes + +## Configuration Change Review (CRITICAL FOCUS) + +### Magic Number Detection +For ANY numeric value change in configuration files: +- **ALWAYS QUESTION**: "Why this specific value? What's the justification?" +- **REQUIRE EVIDENCE**: Has this been tested under production-like load? +- **CHECK BOUNDS**: Is this within recommended ranges for your system? +- **ASSESS IMPACT**: What happens if this limit is reached? + +### Common Risky Configuration Patterns + +#### Connection Pool Settings +``` +# DANGER ZONES - Always flag these: +- pool size reduced (can cause connection starvation) +- pool size dramatically increased (can overload database) +- timeout values changed (can cause cascading failures) +- idle connection settings modified (affects resource usage) +``` +Questions to ask: +- "How many concurrent users does this support?" +- "What happens when all connections are in use?" +- "Has this been tested with your actual workload?" +- "What's your database's max connection limit?" + +#### Timeout Configurations +``` +# HIGH RISK - These cause cascading failures: +- Request timeouts increased (can cause thread exhaustion) +- Connection timeouts reduced (can cause false failures) +- Read/write timeouts modified (affects user experience) +``` +Questions to ask: +- "What's the 95th percentile response time in production?" +- "How will this interact with upstream/downstream timeouts?" +- "What happens when this timeout is hit?" + +#### Memory and Resource Limits +``` +# CRITICAL - Can cause OOM or waste resources: +- Heap size changes +- Buffer sizes +- Cache limits +- Thread pool sizes +``` +Questions to ask: +- "What's the current memory usage pattern?" +- "Have you profiled this under load?" +- "What's the impact on garbage collection?" + +### Common Configuration Vulnerabilities by Category + +#### Database Connection Pools +Critical patterns to review: +``` +# Common outage causes: +- Maximum pool size too low → connection starvation +- Connection acquisition timeout too low → false failures +- Idle timeout misconfigured → excessive connection churn +- Connection lifetime exceeding database timeout → stale connections +- Pool size not accounting for concurrent workers → resource contention +``` +Key formula: `pool_size >= (threads_per_worker × worker_count)` + +#### Security Configuration +High-risk patterns: +``` +# CRITICAL misconfigurations: +- Debug/development mode enabled in production +- Wildcard host allowlists (accepting connections from anywhere) +- Overly long session timeouts (security risk) +- Exposed management endpoints or admin interfaces +- SQL query logging enabled (information disclosure) +- Verbose error messages revealing system internals +``` + +#### Application Settings +Danger zones: +``` +# Connection and caching: +- Connection age limits (0 = no pooling, too high = stale data) +- Cache TTLs that don't match usage patterns +- Reaping/cleanup frequencies affecting resource recycling +- Queue depths and worker ratios misaligned +``` + +### Impact Analysis Requirements + +For EVERY configuration change, require answers to: +1. **Load Testing**: "Has this been tested with production-level load?" +2. **Rollback Plan**: "How quickly can this be reverted if issues occur?" +3. **Monitoring**: "What metrics will indicate if this change causes problems?" +4. **Dependencies**: "How does this interact with other system limits?" +5. **Historical Context**: "Have similar changes caused issues before?" + +## Standard Code Review Checklist + +- Code is simple and readable +- Functions and variables are well-named +- No duplicated code +- Proper error handling with specific error types +- No exposed secrets, API keys, or credentials +- Input validation and sanitization implemented +- Good test coverage including edge cases +- Performance considerations addressed +- Security best practices followed +- Documentation updated for significant changes + +## Review Output Format + +Organize feedback by severity with configuration issues prioritized: + +### 🚨 CRITICAL (Must fix before deployment) +- Configuration changes that could cause outages +- Security vulnerabilities +- Data loss risks +- Breaking changes + +### ⚠️ HIGH PRIORITY (Should fix) +- Performance degradation risks +- Maintainability issues +- Missing error handling + +### 💡 SUGGESTIONS (Consider improving) +- Code style improvements +- Optimization opportunities +- Additional test coverage + +## Configuration Change Skepticism + +Adopt a "prove it's safe" mentality for configuration changes: +- Default position: "This change is risky until proven otherwise" +- Require justification with data, not assumptions +- Suggest safer incremental changes when possible +- Recommend feature flags for risky modifications +- Insist on monitoring and alerting for new limits + +## Real-World Outage Patterns to Check + +Based on 2024 production incidents: +1. **Connection Pool Exhaustion**: Pool size too small for load +2. **Timeout Cascades**: Mismatched timeouts causing failures +3. **Memory Pressure**: Limits set without considering actual usage +4. **Thread Starvation**: Worker/connection ratios misconfigured +5. **Cache Stampedes**: TTL and size limits causing thundering herds + +Remember: Configuration changes that "just change numbers" are often the most dangerous. A single wrong value can bring down an entire system. Be the guardian who prevents these outages. diff --git a/.claude/agents/deployment-engineer.md b/.claude/agents/deployment-engineer.md new file mode 100644 index 0000000..9cba8fd --- /dev/null +++ b/.claude/agents/deployment-engineer.md @@ -0,0 +1,32 @@ +--- +name: deployment-engineer +description: Configure CI/CD pipelines, Docker containers, and cloud deployments. Handles GitHub Actions, Kubernetes, and infrastructure automation. Use PROACTIVELY when setting up deployments, containers, or CI/CD workflows. +model: sonnet +--- + +You are a deployment engineer specializing in automated deployments and container orchestration. + +## Focus Areas +- CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins) +- Docker containerization and multi-stage builds +- Kubernetes deployments and services +- Infrastructure as Code (Terraform, CloudFormation) +- Monitoring and logging setup +- Zero-downtime deployment strategies + +## Approach +1. Automate everything - no manual deployment steps +2. Build once, deploy anywhere (environment configs) +3. Fast feedback loops - fail early in pipelines +4. Immutable infrastructure principles +5. Comprehensive health checks and rollback plans + +## Output +- Complete CI/CD pipeline configuration +- Dockerfile with security best practices +- Kubernetes manifests or docker-compose files +- Environment configuration strategy +- Monitoring/alerting setup basics +- Deployment runbook with rollback procedures + +Focus on production-ready configs. Include comments explaining critical decisions. diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md new file mode 100644 index 0000000..bbe4bbc --- /dev/null +++ b/.claude/agents/frontend-developer.md @@ -0,0 +1,30 @@ +--- +name: frontend-developer +description: Build React components, implement responsive layouts, and handle client-side state management. Optimizes frontend performance and ensures accessibility. Use PROACTIVELY when creating UI components or fixing frontend issues. +--- + +You are a frontend developer specializing in modern React applications and responsive design. + +## Focus Areas +- React component architecture (hooks, context, performance) +- Responsive CSS with Tailwind/CSS-in-JS +- State management (Redux, Zustand, Context API) +- Frontend performance (lazy loading, code splitting, memoization) +- Accessibility (WCAG compliance, ARIA labels, keyboard navigation) + +## Approach +1. Component-first thinking - reusable, composable UI pieces +2. Mobile-first responsive design +3. Performance budgets - aim for sub-3s load times +4. Semantic HTML and proper ARIA attributes +5. Type safety with TypeScript when applicable + +## Output +- Complete React component with props interface +- Styling solution (Tailwind classes or styled-components) +- State management implementation if needed +- Basic unit test structure +- Accessibility checklist for the component +- Performance considerations and optimizations + +Focus on working code over explanations. Include usage examples in comments. diff --git a/.claude/agents/javascript-pro.md b/.claude/agents/javascript-pro.md new file mode 100644 index 0000000..0233792 --- /dev/null +++ b/.claude/agents/javascript-pro.md @@ -0,0 +1,35 @@ +--- +name: javascript-pro +description: Master modern JavaScript with ES6+, async patterns, and Node.js APIs. Handles promises, event loops, and browser/Node compatibility. Use PROACTIVELY for JavaScript optimization, async debugging, or complex JS patterns. +model: sonnet +--- + +You are a JavaScript expert specializing in modern JS and async programming. + +## Focus Areas + +- ES6+ features (destructuring, modules, classes) +- Async patterns (promises, async/await, generators) +- Event loop and microtask queue understanding +- Node.js APIs and performance optimization +- Browser APIs and cross-browser compatibility +- TypeScript migration and type safety + +## Approach + +1. Prefer async/await over promise chains +2. Use functional patterns where appropriate +3. Handle errors at appropriate boundaries +4. Avoid callback hell with modern patterns +5. Consider bundle size for browser code + +## Output + +- Modern JavaScript with proper error handling +- Async code with race condition prevention +- Module structure with clean exports +- Jest tests with async test patterns +- Performance profiling results +- Polyfill strategy for browser compatibility + +Support both Node.js and browser environments. Include JSDoc comments. diff --git a/.claude/agents/senior-code-reviewer.md b/.claude/agents/senior-code-reviewer.md new file mode 100644 index 0000000..48a461b --- /dev/null +++ b/.claude/agents/senior-code-reviewer.md @@ -0,0 +1,35 @@ +--- +name: senior-code-reviewer +description: Use this agent when you need expert code review for recently written or modified code. This agent performs thorough analysis of code quality, architecture, performance, security, and maintainability. Perfect for reviewing new features, refactored code, or when you want a senior developer's perspective on your implementation. Examples: Context: The user has just implemented a new feature or function and wants it reviewed. user: "I've just written a new caching mechanism for our API" assistant: "I'll use the senior-code-reviewer agent to analyze your caching implementation" Since the user has written new code and wants feedback, use the Task tool to launch the senior-code-reviewer agent. Context: The user has refactored existing code and wants validation. user: "I've refactored the authentication module to use async/await" assistant: "Let me have the senior-code-reviewer agent examine your refactoring work" The user has made changes to existing code, so use the senior-code-reviewer agent to review the refactoring. +--- + +You are a senior software engineer with 15+ years of experience across multiple domains and technology stacks. You have deep expertise in software architecture, design patterns, performance optimization, security best practices, and code maintainability. Your role is to provide thorough, constructive code reviews that help developers improve their craft. + +When reviewing code, you will: + +1. **Analyze Code Quality**: Examine the recently written or modified code for clarity, readability, and adherence to best practices. Look for code smells, anti-patterns, and opportunities for improvement. Focus on the specific changes or additions rather than the entire codebase unless explicitly asked. + +2. **Evaluate Architecture & Design**: Assess whether the code follows SOLID principles, uses appropriate design patterns, and maintains proper separation of concerns. Consider how well the new code integrates with existing architecture. + +3. **Check Performance**: Identify potential performance bottlenecks, inefficient algorithms, or resource leaks. Suggest optimizations where appropriate, but balance performance with readability. + +4. **Security Assessment**: Look for common security vulnerabilities like injection risks, improper input validation, exposed sensitive data, or authentication/authorization issues. Highlight any security concerns with severity levels. + +5. **Maintainability Review**: Evaluate how easy the code will be to maintain, extend, and debug. Check for proper error handling, logging, documentation, and test coverage. + +6. **Provide Constructive Feedback**: Structure your review with: + - **Strengths**: What the developer did well + - **Critical Issues**: Must-fix problems that could cause bugs or security issues + - **Suggestions**: Improvements for better code quality + - **Nitpicks**: Minor style or convention issues (clearly marked as optional) + +Your review format should be: +- Start with a brief summary of what you reviewed +- Use clear headings for different aspects (Quality, Security, Performance, etc.) +- Provide specific line references or code snippets when pointing out issues +- Include code examples for suggested improvements +- End with actionable next steps prioritized by importance + +Be thorough but respectful. Remember that code review is about improving the code and helping developers grow, not about showing superiority. Acknowledge good practices and clever solutions. When suggesting changes, explain the 'why' behind your recommendations. + +If you need more context about the code's purpose, requirements, or constraints, proactively ask for clarification. Consider the project's established patterns and practices when making recommendations. diff --git a/.claude/agents/ui-ux-designer.md b/.claude/agents/ui-ux-designer.md new file mode 100644 index 0000000..ebe5a9b --- /dev/null +++ b/.claude/agents/ui-ux-designer.md @@ -0,0 +1,35 @@ +--- +name: ui-ux-designer +description: Create interface designs, wireframes, and design systems. Masters user research, prototyping, and accessibility standards. Use PROACTIVELY for design systems, user flows, or interface optimization. +model: sonnet +--- + +You are a UI/UX designer specializing in user-centered design and interface systems. + +## Focus Areas + +- User research and persona development +- Wireframing and prototyping workflows +- Design system creation and maintenance +- Accessibility and inclusive design principles +- Information architecture and user flows +- Usability testing and iteration strategies + +## Approach + +1. User needs first - design with empathy and data +2. Progressive disclosure for complex interfaces +3. Consistent design patterns and components +4. Mobile-first responsive design thinking +5. Accessibility built-in from the start + +## Output + +- User journey maps and flow diagrams +- Low and high-fidelity wireframes +- Design system components and guidelines +- Prototype specifications for development +- Accessibility annotations and requirements +- Usability testing plans and metrics + +Focus on solving user problems. Include design rationale and implementation notes. \ No newline at end of file diff --git a/.claude/commands/dev/refactor-code.md b/.claude/commands/dev/refactor-code.md new file mode 100644 index 0000000..9032473 --- /dev/null +++ b/.claude/commands/dev/refactor-code.md @@ -0,0 +1,144 @@ +# Project Overview: Loop Machine + +**Loop Machine** is a web-based drum machine and sequencer. It allows users to create, play, and manipulate drum patterns using web audio, with a focus on usability and state persistence. + +## Main Features +- **Step Sequencer:** 16-step grid for programming drum patterns (kick, snare, hi-hat by default). +- **Audio Engine:** Uses the Web Audio API to load and play drum samples. +- **Effects:** Per-instrument reverb and delay, with real-time control. +- **State Persistence:** Patterns and effect settings can be encoded/decoded for URL or JSON-based state sharing. +- **UI:** Modern, responsive interface with a sidebar for JSON state editing and controls for play/stop, reset, and effect sliders. + +## Key Files & Structure +- **index.html:** Main entry point, sets up the UI structure and loads scripts/styles. +- **script.js:** Main application logic (audio, sequencer, state, UI, event handling). +- **style.css:** Modern, responsive layout and visual feedback for sequencer and controls. +- **script.test.js:** Jest-based test suite for core logic (state, timing, audio loading). +- **Sample Libraries:** Large collections of drum samples (808, 909, etc.) in organized folders. + +## Development & Quality +- **Testing:** Good coverage of core logic, logic-focused tests, documented setup. +- **Linting/Formatting:** ESLint, Prettier, Husky, and lint-staged for code quality. +- **Extensibility:** Modular code, easy to add instruments/effects, clear separation of concerns. + +## Summary +- Mature, well-structured web drum machine. +- Modern UI, robust audio engine, and good test coverage for core logic. +- Ready for further feature development, UI enhancements, or expansion of sample libraries. + +# Intelligently Refactor and Improve Code Quality + +Intelligently refactor and improve code quality + +## Instructions + +Follow this systematic approach to refactor code: **$ARGUMENTS** + +1. **Pre-Refactoring Analysis** + - Identify the code that needs refactoring and the reasons why + - Understand the current functionality and behavior completely + - Review existing tests and documentation + - Identify all dependencies and usage points + +2. **Test Coverage Verification** + - Ensure comprehensive test coverage exists for the code being refactored + - If tests are missing, write them BEFORE starting refactoring + - Run all tests to establish a baseline + - Document current behavior with additional tests if needed + +3. **Refactoring Strategy** + - Define clear goals for the refactoring (performance, readability, maintainability) + - Choose appropriate refactoring techniques: + - Extract Method/Function + - Extract Class/Component + - Rename Variable/Method + - Move Method/Field + - Replace Conditional with Polymorphism + - Eliminate Dead Code + - Plan the refactoring in small, incremental steps + +4. **Environment Setup** + - Create a new branch: `git checkout -b refactor/$ARGUMENTS` + - Ensure all tests pass before starting + - Set up any additional tooling needed (profilers, analyzers) + +5. **Incremental Refactoring** + - Make small, focused changes one at a time + - Run tests after each change to ensure nothing breaks + - Commit working changes frequently with descriptive messages + - Use IDE refactoring tools when available for safety + +6. **Code Quality Improvements** + - Improve naming conventions for clarity + - Eliminate code duplication (DRY principle) + - Simplify complex conditional logic + - Reduce method/function length and complexity + - Improve separation of concerns + +7. **Performance Optimizations** + - Identify and eliminate performance bottlenecks + - Optimize algorithms and data structures + - Reduce unnecessary computations + - Improve memory usage patterns + +8. **Design Pattern Application** + - Apply appropriate design patterns where beneficial + - Improve abstraction and encapsulation + - Ensure SOLID principles are followed + - Consider dependency injection where appropriate + +9. **Error Handling Enhancement** + - Improve error messages and logging + - Add proper exception handling + - Implement circuit breakers for external dependencies + - Add retry logic with exponential backoff where appropriate + +10. **Documentation Updates** + - Update code comments to reflect changes + - Revise API documentation + - Update architectural diagrams if needed + - Document any breaking changes + +11. **Final Verification** + - Run full test suite + - Perform code coverage analysis + - Run static analysis tools + - Review performance metrics + - Conduct peer code review + +12. **Commit and Document** + - Create clear, descriptive commit messages + - Document the refactoring rationale in PR description + - Include before/after comparisons if helpful + - Note any potential risks or considerations + +## Refactoring Principles +- **Make it work, make it right, make it fast** - in that order +- **Leave the code better than you found it** +- **Refactor in small steps with continuous testing** +- **Don't mix refactoring with feature changes** +- **Keep behavior unchanged during pure refactoring** + +## Common Code Smells to Address +- Long methods/functions +- Large classes +- Duplicate code +- Complex conditional expressions +- Primitive obsession +- Feature envy +- Data clumps +- Switch statements +- Parallel inheritance hierarchies +- Lazy classes +- Speculative generality +- Temporary fields +- Message chains +- Middle man +- Inappropriate intimacy +- Alternative classes with different interfaces +- Incomplete library classes +- Data classes +- Refused bequest +- Comments explaining complex code + +Remember: The goal is to improve code quality while maintaining existing functionality. Always prioritize clarity and maintainability over cleverness. \ No newline at end of file diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout new file mode 100755 index 0000000..5abf8ed --- /dev/null +++ b/.husky/_/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit new file mode 100755 index 0000000..b8b76c2 --- /dev/null +++ b/.husky/_/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge new file mode 100755 index 0000000..726f909 --- /dev/null +++ b/.husky/_/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push new file mode 100755 index 0000000..5f26dc4 --- /dev/null +++ b/.husky/_/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..a6c7709 --- /dev/null +++ b/PRD.md @@ -0,0 +1,46 @@ +# Loop Machine PRD + +## Overview +**Product**: Loop Machine — browser-based drum sequencer & arpeggiator +**Target Users**: Beginner musicians and hobbyist producers +**Purpose**: Personal roadmap for feature development + +## Current State (v1) +- 16-step sequencer with 3 tracks (kick, snare, hi-hat) +- Per-track reverb effects +- Volume per instrument +- URL state persistence (shareable patterns) +- 120 BPM fixed tempo + +## Goals +- Keep it simple — fun to use in under 30 seconds +- Low latency (<50ms response time) +- Works offline after initial load + +## Non-Goals +- ❌ Full DAW (no recording, arrangement, mixing) + + +## Roadmap + +### P0 — Must Have +- **Tempo control** — editable BPM input field +- **Playable keyboard** — activate piano keys for melodic input +- **Arpeggiator** — auto-cycle held notes, synced to sequencer tempo + - Direction (up/down/up-down) + - Rate (1/4, 1/8, 1/16) + +### P1 — Should Have +- **Pitch bend** — slide synth pitch up/down +- **Filter** — hi-pass/lo-pass sweep for synth +- **Visual waveform** — real-time audio visualization + +### P2 — Nice to Have +- **More instruments** — bass, claps, toms from existing 808 samples +- **Preset patterns** — built-in starter beats + +## Technical Constraints +- Web Audio API only (no external audio libs) +- Must maintain <50ms latency +- URL state encoding must stay compact +- No build-time dependencies beyond Vite/React \ No newline at end of file diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000..ff2be78 Binary files /dev/null and b/assets/.DS_Store differ diff --git a/docs/RECOMMENDATIONS.md b/docs/RECOMMENDATIONS.md new file mode 100644 index 0000000..5c7bb52 --- /dev/null +++ b/docs/RECOMMENDATIONS.md @@ -0,0 +1,358 @@ +# Loop Machine - Review Recommendations + +A consolidated list of recommendations from four expert perspectives: Software Architect, Frontend Engineer, Product Designer, and Music Producer. + +--- + +## Priority Legend + +- **P0**: Critical - blocking issues that should be addressed first +- **P1**: High - significant improvements for next sprint +- **P2**: Medium - technical debt reduction +- **P3**: Low - nice to have enhancements + +--- + +## P0 - Critical + +### 1. Consolidate Dual AudioContext + +**Problem**: The application creates two separate `AudioContext` instances - one in `useAudioEngine.js` for sample playback and another in `useSynthEngine.js` for oscillator synthesis. This makes it impossible to synchronize timing between drums and synth, wastes browser resources, and can hit browser limits on AudioContext instances. + +**Solution**: Create a shared `AudioContextService` singleton that both engines consume. Pass the shared context as a dependency to `useAudioEngine` and `useSynthEngine` hooks. + +**Files**: `src/hooks/useAudioEngine.js`, `src/hooks/useSynthEngine.js` + +--- + +### 2. Implement Real Reverb + +**Problem**: The current "reverb" implementation is just a gain node mixing dry signal back into itself. It produces no actual reverb effect and is musically useless. + +**Solution**: Use Web Audio's `ConvolverNode` with an impulse response file to create actual reverb. Alternatively, implement an algorithmic reverb using feedback delay networks. + +**Files**: `src/hooks/useAudioEngine.js` + +--- + +### 3. Add Swing/Shuffle to Sequencer + +**Problem**: All notes play exactly on the grid with no groove. This makes patterns sound robotic and lifeless. Swing is essential for 90% of modern music production. + +**Solution**: Add a swing parameter (0-75%) that delays every other 16th note by a percentage of the step duration. Apply the offset in the scheduler when calculating `nextNoteTime`. + +**Files**: `src/hooks/useSequencer.js` + +--- + +### 4. Add Keyboard Accessibility to Fader + +**Problem**: The Fader component has `tabIndex={0}` allowing focus, but no `onKeyDown` handler. Keyboard users cannot adjust fader values, violating WCAG accessibility guidelines. + +**Solution**: Add keyboard event handler supporting Arrow keys (increment/decrement), Home/End (min/max), and Page Up/Down (larger jumps). + +**Files**: `src/components/ui/Fader.jsx` + +--- + +### 5. Add Section Headers to UI + +**Problem**: The interface presents drum tracks, arpeggiator, keyboard, and synth controls without visual grouping. Users cannot tell this is both a drum machine AND a melodic tool. The dual nature is confusing. + +**Solution**: Add clear section headers ("DRUM SEQUENCER", "ARPEGGIATOR & SYNTH") with visual dividers between sections. + +**Files**: `src/components/Sequencer.jsx`, `src/index.css` + +--- + +## P1 - High Priority + +### 6. Add Velocity/Dynamics Per Step + +**Problem**: Every drum hit plays at identical volume. Real music has dynamics - accented hi-hats, ghost notes on snare. Without velocity, patterns sound flat and mechanical. + +**Solution**: Add velocity property to each step (at least 3 levels: soft/medium/hard). Visual indicator on pads showing velocity level. Apply velocity as gain multiplier when triggering samples. + +**Files**: `src/hooks/useSequencer.js`, `src/components/Pad.jsx`, `src/hooks/useAudioEngine.js` + +--- + +### 7. Add Filter to Synth Engine + +**Problem**: The synth has no filter - the defining characteristic of subtractive synthesis. Users cannot shape the tone at all, making it useless for creating varied sounds. + +**Solution**: Add `BiquadFilterNode` (lowpass) with cutoff frequency (20Hz-20kHz) and resonance (Q: 0.5-10) controls. Expose filter ADSR envelope for sweep effects. + +**Files**: `src/hooks/useSynthEngine.js`, `src/components/SynthControls.jsx` + +--- + +### 8. Fix Piano Key Interaction Model + +**Problem**: Piano keys toggle on/off (latching behavior) instead of playing momentarily like real piano keys. This breaks user expectations and makes the keyboard feel unnatural. + +**Solution**: Add immediate audio feedback when clicking keys (play the note). Use visual distinction between "pressed" (momentary preview) and "latched" (held for arpeggiator). Consider shift+click for latch mode. + +**Files**: `src/components/Key.jsx`, `src/components/Keyboard.jsx` + +--- + +### 9. Add ARIA Labels Throughout + +**Problem**: Interactive elements (pads, transport buttons, arp controls) lack ARIA labels. Screen reader users cannot understand or navigate the interface. + +**Solution**: Add descriptive `aria-label` attributes to all interactive elements. Add `aria-pressed` to toggle buttons. Create visually hidden live region for announcing state changes. + +**Files**: `src/components/Pad.jsx`, `src/components/TransportControls.jsx`, `src/components/ArpControls.jsx` + +--- + +### 10. Split useSequencer Into Focused Hooks + +**Problem**: `useSequencer` handles 8+ responsibilities: pattern state, track settings, BPM, playback control, audio scheduling, visual sync, URL persistence, and arpeggiator coordination. This violates Single Responsibility Principle and makes the code hard to maintain. + +**Solution**: Extract into focused hooks: +- `usePlaybackClock` - timing and scheduling events +- `usePatternState` - pattern CRUD operations +- `useTrackSettings` - per-track configuration +- `useUrlPersistence` - URL state encoding/decoding + +**Files**: `src/hooks/useSequencer.js` + +--- + +## P2 - Medium Priority + +### 11. Remove Unused Tailwind CSS + +**Problem**: Tailwind CSS is imported in `index.css` but not used anywhere. This adds 50KB+ to the bundle size for no benefit. + +**Solution**: Either remove Tailwind entirely from the project, or migrate existing vanilla CSS to Tailwind classes for consistency. + +**Files**: `src/index.css`, `package.json` + +--- + +### 12. Add Multi-Bar Pattern Support + +**Problem**: Only 1-bar (16-step) patterns are possible. Musicians need 2-4 bar phrases to build verse/chorus structures and create fills on bar 4. + +**Solution**: Add pattern length selector (1/2/4 bars). Update URL encoding to support variable length. Add visual bar markers in the step grid. + +**Files**: `src/hooks/useSequencer.js`, `src/components/Track.jsx`, `src/utils/urlState.js` + +--- + +### 13. Implement Undo/Redo + +**Problem**: No way to recover from accidental changes. Clearing a pattern is permanent. This creates anxiety during creative experimentation. + +**Solution**: Implement command pattern with undo stack. Track pattern changes, track setting changes, and BPM changes. Bind to Cmd+Z / Cmd+Shift+Z. + +**Files**: `src/hooks/useSequencer.js` (new `useUndoRedo.js` hook) + +--- + +### 14. Add Error Boundaries for Web Audio + +**Problem**: If the Web Audio API fails to initialize (unsupported browser, permission denied), the entire app crashes with no user feedback. + +**Solution**: Create `AudioErrorBoundary` component wrapping audio-dependent sections. Display friendly error message with browser compatibility info. + +**Files**: New `src/components/AudioErrorBoundary.jsx`, `src/App.jsx` + +--- + +### 15. Memoize Expensive Operations + +**Problem**: Several computations run on every render unnecessarily: +- `padGroups` array creation in `Track.jsx` +- `getSortedNotes()` in `useArpeggiator.js` runs every scheduler tick +- `instruments` array may not be memoized in parent + +**Solution**: Wrap computations in `useMemo` with appropriate dependency arrays. Cache sorted notes and only recalculate when `heldNotes` changes. + +**Files**: `src/components/Track.jsx`, `src/hooks/useArpeggiator.js`, `src/components/DrumMachine.jsx` + +--- + +### 16. Fix Memory Leak in Arpeggiator + +**Problem**: In `useArpeggiator.js`, if `noteName` changes rapidly, the previous `stopNote` call might not execute before the new note starts, potentially leaving notes playing. + +**Solution**: Track all active notes and ensure cleanup. Use a Map to store note references and clear them properly in the stop logic. + +**Files**: `src/hooks/useArpeggiator.js` + +--- + +### 17. Add Focus Indicators + +**Problem**: No visible focus indicators for keyboard navigation. Users tabbing through the interface cannot see which element is focused. + +**Solution**: Add `:focus-visible` styles with clear outline (e.g., `outline: 3px solid #f5a623; outline-offset: 2px;`) to all interactive elements. + +**Files**: `src/index.css` + +--- + +### 18. Add Reset Confirmation Dialog + +**Problem**: The reset button immediately clears all patterns with no confirmation. This destructive action has no safeguard against accidental clicks. + +**Solution**: Add confirmation modal: "Reset all patterns? This cannot be undone." Include "Don't ask again" checkbox stored in localStorage. + +**Files**: `src/components/TransportControls.jsx`, new `src/components/ConfirmDialog.jsx` + +--- + +### 19. Improve QWERTY Key Hint Visibility + +**Problem**: Keyboard shortcut hints on piano keys are 10px at 50% opacity - nearly invisible. Users don't discover the QWERTY input feature. + +**Solution**: Increase to 12px minimum, 70-75% opacity. Consider adding a help tooltip or overlay explaining keyboard shortcuts. + +**Files**: `src/components/Key.jsx`, `src/index.css` + +--- + +### 20. Label Reset Button + +**Problem**: The reset button is an empty gray box with no icon or text. Users cannot identify its purpose without hovering or clicking. + +**Solution**: Add icon (circular arrow or X) and/or text label ("RST" or "CLEAR"). + +**Files**: `src/components/TransportControls.jsx` + +--- + +## P3 - Nice to Have + +### 21. Add More Instrument Slots + +**Problem**: Only 3 drum sounds (kick, snare, hi-hat) limits creative possibilities. Complete beats need 8-12 sounds minimum (toms, claps, rimshot, cowbell, etc.). + +**Solution**: Expand instrument configuration to support 8+ slots. Add sample browser or preset packs. + +**Files**: `src/config/instruments.js`, `src/hooks/useAudioEngine.js` + +--- + +### 22. Add Pattern Copy/Paste + +**Problem**: Cannot duplicate a good pattern and modify it. Cannot copy just the hi-hat pattern to experiment with variations. + +**Solution**: Add copy/paste buttons per track. Store pattern in clipboard. Support cross-track pasting. + +**Files**: `src/components/Track.jsx`, `src/hooks/useSequencer.js` + +--- + +### 23. Add Tempo-Synced Delay + +**Problem**: Delay time is in milliseconds (0-500ms) which doesn't relate to musical timing. Producers think in note divisions (1/4, 1/8, 1/16). + +**Solution**: Calculate delay time from BPM and note division. Add dropdown for note division selection. Show both ms and note division. + +**Files**: `src/hooks/useAudioEngine.js`, `src/components/TrackControls.jsx` + +--- + +### 24. Add First-Time User Tutorial + +**Problem**: New users don't understand the dual drum/synth nature or the keyboard toggle behavior. Features are discovered by accident or not at all. + +**Solution**: Add welcome modal with quick tutorial for first-time visitors. Animated arrows showing basic workflow. Store "seen tutorial" flag in localStorage. + +**Files**: New `src/components/Tutorial.jsx`, `src/App.jsx` + +--- + +### 25. Migrate to TypeScript + +**Problem**: Vanilla JavaScript provides no type safety. Prop mismatches, incorrect function signatures, and missing hook dependencies are caught at runtime instead of compile time. + +**Solution**: Gradually migrate to TypeScript starting with type definitions for core interfaces (Instrument, Pattern, TrackSettings). Add strict mode over time. + +**Files**: All `.js` and `.jsx` files + +--- + +### 26. Add Synth ADSR Controls + +**Problem**: Synth envelope (Attack, Decay, Sustain, Release) is hardcoded. Cannot create plucky sounds (fast attack/release) vs pad sounds (slow attack). + +**Solution**: Expose ADSR parameters in SynthControls UI. Add knobs/faders for each parameter with reasonable ranges. + +**Files**: `src/hooks/useSynthEngine.js`, `src/components/SynthControls.jsx` + +--- + +### 27. Add Arpeggiator Octave Range + +**Problem**: Arpeggiator plays held notes in a single octave only. Producers often want arpeggios spanning 2-3 octaves for richer patterns. + +**Solution**: Add octave range selector (1-3 octaves). Duplicate held notes across octaves in the note sorting logic. + +**Files**: `src/hooks/useArpeggiator.js`, `src/components/ArpControls.jsx` + +--- + +### 28. Implement Event-Driven Scheduling + +**Problem**: The scheduler directly calls `arpeggiator.scheduleArpNote()`. Adding new features (bass sequencer, chord machine) requires modifying the core scheduler, violating Open/Closed Principle. + +**Solution**: Have scheduler emit timing events `{ step, time, stepDuration }`. Features subscribe to events independently. Use EventEmitter or custom pub/sub pattern. + +**Files**: `src/hooks/useSequencer.js`, `src/hooks/useArpeggiator.js` + +--- + +### 29. Add Probability Per Step + +**Problem**: Patterns are completely deterministic. No way to add subtle variation or generative elements to beats. + +**Solution**: Add probability property per step (0-100%). Roll random number on each trigger. Visual indicator showing probability level on pads. + +**Files**: `src/hooks/useSequencer.js`, `src/components/Pad.jsx` + +--- + +### 30. Add Keyboard Octave Shift + +**Problem**: 2-octave keyboard range (C3-B4) is limiting. Bass sounds need lower octaves, lead sounds need higher. + +**Solution**: Add octave shift buttons (+/- octave) to keyboard section. Update frequency calculations based on octave offset. + +**Files**: `src/components/Keyboard.jsx`, `src/config/keyboard.js` + +--- + +## Summary Statistics + +| Priority | Count | Focus Area | +|----------|-------|------------| +| P0 Critical | 5 | Audio sync, real reverb, swing, accessibility, UX clarity | +| P1 High | 5 | Velocity, filter, interactions, ARIA, code architecture | +| P2 Medium | 10 | Bundle size, features, error handling, performance | +| P3 Low | 10 | Extended features, TypeScript, advanced music production | + +**Total Recommendations**: 30 + +--- + +## Quick Wins (< 1 hour each) + +1. Remove unused Tailwind CSS (#11) +2. Add focus indicators (#17) +3. Label reset button (#20) +4. Improve QWERTY hint visibility (#19) +5. Add ARIA labels to pads (#9) + +## High Impact (Worth the Investment) + +1. Consolidate AudioContext (#1) - Unlocks proper sync +2. Real reverb (#2) - Makes effects actually work +3. Add swing (#3) - Makes beats feel alive +4. Add velocity (#6) - Adds musical dynamics +5. Add filter to synth (#7) - Makes synth usable diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..cf6b73b --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,640 @@ +# Loop Machine Style Guide Implementation Roadmap + +## Overview + +This roadmap outlines the implementation strategy for transforming the Loop Machine into a fully polished, accessible, and responsive application based on **The 1984 Digital Control System** documented in `./design/DESIGN_SYSTEM.md`. + +**Total Estimated Effort:** 44-62 hours (6-8 days) + +**Tech Stack:** +- Tailwind CSS v4 (already installed via `@tailwindcss/vite`) +- Radix UI primitives (for accessible slider/toggle components) + +--- + +## Executive Summary + +| Phase | Focus Area | Effort | Priority | +|-------|-----------|--------|----------| +| 1 | Tailwind Theme Configuration | 10-14 hrs | P0 | +| 2 | Accessibility Remediation | 24-34 hrs | P0 | +| 3 | Responsive Design | 10-14 days | P1 | + +--- + +## Phase 1: Tailwind Theme Configuration + +**Goal:** Configure Tailwind with custom design tokens and migrate from hardcoded CSS values to utility classes where appropriate. + +### Statistics +- Total hex color values: 103 +- Unique colors: 77 +- Gradients to tokenize: 38 +- Shadow declarations: 15+ +- Spacing values: 20+ +- Border-radius values: 33+ + +### Implementation Steps + +#### Step 1.1: Create Tailwind Config (1-2 hrs) + +Create `tailwind.config.js` with the 1984 Digital Control System tokens (see `./design/DESIGN_SYSTEM.md`): + +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + colors: { + // Core Neutrals + cream: '#E7DFD1', + beige: '#D8CFBF', + graphite: '#1F2021', + + // Pads & Drums + pad: { + grey: '#A9A9A9', + outline: '#676F6F', + }, + accent: { + orange: '#ED7A24', + amber: '#F2A33C', + }, + + // Synth Section + synth: { + blue: '#4A70A8', + teal: '#3F8F8C', + yellow: '#E5C26E', + violet: '#6E5A86', + mint: '#9BF3D3', + }, + + // LED Display + led: { + red: '#E34234', + bg: '#151515', + }, + }, + boxShadow: { + surface: '0 2px 4px rgba(0,0,0,0.2)', + 'inset-subtle': 'inset 0 1px 2px rgba(0,0,0,0.3)', + 'glow-orange': '0 0 8px rgba(237,122,36,0.4)', + 'glow-mint': '0 0 8px rgba(155,243,211,0.4)', + }, + fontFamily: { + display: ['Eurostile', 'Microgramma', 'sans-serif'], + sans: ['DIN', 'Roboto Condensed', 'sans-serif'], + led: ['DSEG7', 'Digital-7', 'monospace'], + }, + fontSize: { + micro: ['9px', { lineHeight: '1' }], + xs: ['10px', { lineHeight: '1.2' }], + sm: ['12px', { lineHeight: '1.3' }], + }, + letterSpacing: { + display: '0.15em', + header: '0.1em', + label: '0.05em', + }, + spacing: { + '4.5': '18px', + '7.5': '30px', + '9.5': '38px', + }, + }, + }, + plugins: [], +} +``` + +#### Step 1.2: Add CSS Variables via @theme (1 hr) + +In `src/index.css`, use Tailwind v4's `@theme` directive for gradients and complex values: + +```css +@import "tailwindcss"; + +@theme { + /* Surface Gradients */ + --gradient-surface: linear-gradient(180deg, #E7DFD1 0%, #D8CFBF 100%); + --gradient-panel: linear-gradient(180deg, #1F2021 0%, #151515 100%); + + /* Pad States */ + --gradient-pad-inactive: linear-gradient(180deg, #A9A9A9 0%, #8A8A8A 100%); + --gradient-pad-active: linear-gradient(180deg, #ED7A24 0%, #D66A1A 100%); + + /* Synth Section */ + --gradient-synth-blue: linear-gradient(180deg, #4A70A8 0%, #3A5A88 100%); + --gradient-synth-teal: linear-gradient(180deg, #3F8F8C 0%, #2F7F7C 100%); + + /* Keyboard */ + --gradient-key-white: linear-gradient(180deg, #F5F5F5 0%, #E0E0E0 100%); + --gradient-key-black: linear-gradient(180deg, #1F2021 0%, #0A0A0A 100%); + + /* Minimal depth effects */ + --highlight-subtle: inset 0 1px 0 rgba(255,255,255,0.1); + --shadow-inset: inset 0 1px 2px rgba(0,0,0,0.2); +} +``` + +#### Step 1.3: Migrate Component Styles (6-8 hrs) + +**Strategy:** Hybrid approach - use Tailwind utilities for common patterns, keep custom CSS for complex component-specific styles. + +**Migrate to Tailwind utilities:** +- Colors: `bg-cream`, `bg-graphite`, `text-graphite`, `border-pad-outline` +- Accents: `bg-accent-orange`, `text-accent-amber` +- Synth: `bg-synth-blue`, `bg-synth-teal`, `bg-synth-mint` +- LED: `bg-led-bg`, `text-led-red` +- Typography: `font-display`, `font-led`, `tracking-display` +- Focus states: `focus-visible:ring-2 focus-visible:ring-synth-mint` + +**Keep as custom CSS (gradients):** +- Pad gradients (inactive/active states) +- Surface gradients +- Keyboard key gradients + +#### Step 1.4: Component Class Updates (4-6 hrs) + +Example component migration: + +**Before (Pad.jsx):** +```jsx + + ))} + + + + {/* SPEED control */} +
+ SPEED +
+ {SPEEDS.map((s) => ( + + ))} +
+
+ + + {/* VOL control - vertical slider */} +
+ VOL +
+ onVolumeChange(parseFloat(e.target.value))} + /> +
+
+ + {/* WAVE control - vertical buttons */} +
+ WAVE +
+ {WAVEFORMS.map((type) => ( + + ))} +
+
+ + + {/* Keyboard */} + {children} + + ) +} + +/** + * SVG icons for waveform types + */ +function WaveformIcon({ type }) { + const width = 20 + const height = 14 + + switch (type) { + case 'sine': + return ( + + + + ) + case 'square': + return ( + + + + ) + case 'sawtooth': + return ( + + + + ) + case 'triangle': + return ( + + + + ) + default: + return null + } +} + +export default memo(KeyboardControls) diff --git a/src/components/Sequencer.jsx b/src/components/Sequencer.jsx index e5ff8ef..2ff9b85 100644 --- a/src/components/Sequencer.jsx +++ b/src/components/Sequencer.jsx @@ -1,5 +1,8 @@ import Track from './Track' +import ArpTrack from './ArpTrack' +import TrackControls from './TrackControls' import TransportControls from './TransportControls' +import AudioVisualizer from './AudioVisualizer' function Sequencer({ instruments, @@ -11,55 +14,234 @@ function Sequencer({ onTrackSettingsChange, onEffectChange, bpm, + onBpmChange, isPlaying, isLoading, onPlayStop, onReset, + arpeggiator, + synth, + analyser, children, }) { + // Group step numbers into groups of 4 to match pad groups + const stepNumberGroups = [] + for (let g = 0; g < 16; g += 4) { + stepNumberGroups.push( + Array.from({ length: 4 }, (_, i) => g + i + 1) + ) + } + + const handleVolumeChange = (instrumentId, volume) => { + onTrackSettingsChange(instrumentId, { volume }) + } + + const handleReverbChange = (instrumentId, reverb) => { + onTrackSettingsChange(instrumentId, { reverb }) + onEffectChange?.(instrumentId, 'reverb', reverb) + } + + const handleFilterChange = (instrumentId, filter) => { + onTrackSettingsChange(instrumentId, { filter }) + onEffectChange?.(instrumentId, 'filter', filter) + } + return (
- {/* Step numbers and slider labels - top */} -
-
-
- {Array.from({ length: 16 }, (_, i) => ( - {i + 1} + {/* Drums section - step numbers + 3 drum rows + knobs */} +
+
+ {/* Step numbers row */} +
+ {stepNumberGroups.map((group, groupIndex) => ( +
+ {group.map(num => ( + {num} + ))} +
+ ))} +
+ + {/* Instrument steps */} + {instruments.map(instrument => ( + ))}
-
- {/* Tracks */} -
- {instruments.map(instrument => ( - - ))} + {/* Track controls (knobs) */} +
+ {instruments.map(instrument => ( + handleVolumeChange(instrument.id, v)} + onReverbChange={(v) => handleReverbChange(instrument.id, v)} + onFilterChange={(v) => handleFilterChange(instrument.id, v)} + /> + ))} +
- {/* Bottom row: Keyboard + Transport */} -
-
-
- {children} + {/* Synth section - arp steps + keyboard + vol slider + transport */} + {arpeggiator && ( +
+
+ {/* Volume slider on left */} +
+
+ VOL +
+ synth?.setVolume?.(parseFloat(e.target.value))} + /> +
+
+
+ + {/* Synth grid - arp steps + keyboard */} +
+ {/* Arpeggiator steps */} + + + {/* Keyboard */} +
+ {children} +
+
+ + {/* Transport controls on right */} +
+ +
+
+ + {/* Synth controls row - same 3-column layout as synth row */} +
+ {/* Left visualizer */} +
+ +
+ + {/* Center - controls aligned with synth-grid-container */} +
+
+ {/* Direction control */} +
+ DIRECTION +
+ + + +
+
+ + {/* Speed control */} +
+ SPEED +
+ + + +
+
+ + {/* Wave control */} +
+ WAVE +
+ + + + +
+
+
+
+ + {/* Right visualizer */} +
+ +
+
- -
+ )}
) } diff --git a/src/components/Track.jsx b/src/components/Track.jsx index c78f802..41985c2 100644 --- a/src/components/Track.jsx +++ b/src/components/Track.jsx @@ -1,6 +1,5 @@ -import { memo, useCallback } from 'react' +import { memo } from 'react' import Pad from './Pad' -import TrackControls from './TrackControls' const Track = memo(function Track({ instrument, @@ -8,25 +7,8 @@ const Track = memo(function Track({ pattern, currentStep, onToggle, - trackSettings, - onTrackSettingsChange, - onEffectChange, + showLabel = true, }) { - const handleVolumeChange = useCallback((volume) => { - onTrackSettingsChange(instrument.id, { volume }) - onEffectChange?.(instrument.id, 'volume', volume) - }, [instrument.id, onTrackSettingsChange, onEffectChange]) - - const handleReverbChange = useCallback((reverb) => { - onTrackSettingsChange(instrument.id, { reverb }) - onEffectChange?.(instrument.id, 'reverb', reverb) - }, [instrument.id, onTrackSettingsChange, onEffectChange]) - - const handleFilterChange = useCallback((filter) => { - onTrackSettingsChange(instrument.id, { filter }) - onEffectChange?.(instrument.id, 'filter', filter) - }, [instrument.id, onTrackSettingsChange, onEffectChange]) - // Group pads into groups of 4 const padGroups = [] for (let g = 0; g < steps; g += 4) { @@ -36,13 +18,13 @@ const Track = memo(function Track({ } return ( -
-
{instrument.name}
+
+ {instrument.name}
{padGroups.map((group, groupIndex) => (
{group.map(i => ( ))}
-
) }) diff --git a/src/components/TransportControls.jsx b/src/components/TransportControls.jsx index d7de221..a55612b 100644 --- a/src/components/TransportControls.jsx +++ b/src/components/TransportControls.jsx @@ -1,21 +1,64 @@ -function TransportControls({ bpm, isPlaying, isLoading, onPlayStop, onReset }) { +import { useState, useEffect } from 'react' +import Button from './ui/Button' + +function TransportControls({ bpm, isPlaying, isLoading, onPlayStop, onReset, onBpmChange }) { + const [inputValue, setInputValue] = useState(String(bpm)) + + // Sync input when bpm prop changes externally + useEffect(() => { + setInputValue(String(bpm)) + }, [bpm]) + + const handleChange = (e) => { + setInputValue(e.target.value) + } + + const handleBlur = () => { + const parsed = parseInt(inputValue, 10) + if (!isNaN(parsed) && parsed >= 60 && parsed <= 200) { + onBpmChange(parsed) + } else { + // Reset to current bpm if invalid + setInputValue(String(bpm)) + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.target.blur() + } + } + return (
- TEMPO -
- {bpm} +
+ TEMPO +
+ +
- - + + {isPlaying ? 'STOP' : 'START'} +
) diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx new file mode 100644 index 0000000..aa71534 --- /dev/null +++ b/src/components/ui/Button.jsx @@ -0,0 +1,10 @@ +import { memo } from 'react' + +function Button({ variant = '', active = false, disabled = false, onClick, children }) { + const classes = ['btn'] + if (variant) classes.push(...variant.split(' ').map(v => 'btn-' + v)) + if (active) classes.push('active') + return +} + +export default memo(Button) diff --git a/src/components/ui/Fader.jsx b/src/components/ui/Fader.jsx new file mode 100644 index 0000000..5ecdf3c --- /dev/null +++ b/src/components/ui/Fader.jsx @@ -0,0 +1,137 @@ +import { memo, useCallback, useRef } from 'react' + +/** + * Horizontal fader component with tick marks and draggable thumb + * Matches hardware mixer/synth fader aesthetic + */ +const Fader = memo(function Fader({ + value, + onChange, + min = 0, + max = 1, + step = 0.01, +}) { + const trackRef = useRef(null) + const dragStartRef = useRef(null) + + const range = max - min + const normalized = (value - min) / range + + const handleMouseDown = useCallback((e) => { + e.preventDefault() + const track = trackRef.current + if (!track) return + + const rect = track.getBoundingClientRect() + + dragStartRef.current = { + trackLeft: rect.left, + trackWidth: rect.width, + } + + // Set initial value based on click position + const clickX = e.clientX - rect.left + const newNormalized = Math.max(0, Math.min(1, clickX / rect.width)) + const newValue = min + newNormalized * range + const snapped = Math.round(newValue / step) * step + onChange(Math.max(min, Math.min(max, snapped))) + + const handleMouseMove = (e) => { + if (!dragStartRef.current) return + + const { trackLeft, trackWidth } = dragStartRef.current + const currentX = e.clientX - trackLeft + const newNormalized = Math.max(0, Math.min(1, currentX / trackWidth)) + const newValue = min + newNormalized * range + const snapped = Math.round(newValue / step) * step + onChange(Math.max(min, Math.min(max, snapped))) + } + + const handleMouseUp = () => { + dragStartRef.current = null + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [min, max, range, step, onChange]) + + const handleTouchStart = useCallback((e) => { + e.preventDefault() + const track = trackRef.current + if (!track) return + + const rect = track.getBoundingClientRect() + const touch = e.touches[0] + + dragStartRef.current = { + trackLeft: rect.left, + trackWidth: rect.width, + } + + const touchX = touch.clientX - rect.left + const newNormalized = Math.max(0, Math.min(1, touchX / rect.width)) + const newValue = min + newNormalized * range + const snapped = Math.round(newValue / step) * step + onChange(Math.max(min, Math.min(max, snapped))) + + const handleTouchMove = (e) => { + if (!dragStartRef.current) return + const touch = e.touches[0] + + const { trackLeft, trackWidth } = dragStartRef.current + const currentX = touch.clientX - trackLeft + const newNormalized = Math.max(0, Math.min(1, currentX / trackWidth)) + const newValue = min + newNormalized * range + const snapped = Math.round(newValue / step) * step + onChange(Math.max(min, Math.min(max, snapped))) + } + + const handleTouchEnd = () => { + dragStartRef.current = null + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd) + }, [min, max, range, step, onChange]) + + return ( +
+
+ {/* Single tick line above */} +
+ + {/* Track */} +
+ {/* Thumb */} +
+
+
+
+
+
+ + {/* Single tick line below */} +
+
+
+ ) +}) + +export default Fader diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx deleted file mode 100644 index aa6f4cb..0000000 --- a/src/components/ui/button.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva } from "class-variance-authority"; - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}) { - const Comp = asChild ? Slot : "button" - - return ( - - ); -} - -export { Button, buttonVariants } diff --git a/src/config/keyboard.js b/src/config/keyboard.js new file mode 100644 index 0000000..0989b27 --- /dev/null +++ b/src/config/keyboard.js @@ -0,0 +1,80 @@ +/** + * Keyboard configuration for 2-octave synth (C3-B4) + * A4 = 440Hz standard tuning + */ + +export const NOTES = [ + // Octave 3 + { note: 'C3', freq: 130.81, type: 'white' }, + { note: 'C#3', freq: 138.59, type: 'black' }, + { note: 'D3', freq: 146.83, type: 'white' }, + { note: 'D#3', freq: 155.56, type: 'black' }, + { note: 'E3', freq: 164.81, type: 'white' }, + { note: 'F3', freq: 174.61, type: 'white' }, + { note: 'F#3', freq: 185.0, type: 'black' }, + { note: 'G3', freq: 196.0, type: 'white' }, + { note: 'G#3', freq: 207.65, type: 'black' }, + { note: 'A3', freq: 220.0, type: 'white' }, + { note: 'A#3', freq: 233.08, type: 'black' }, + { note: 'B3', freq: 246.94, type: 'white' }, + // Octave 4 + { note: 'C4', freq: 261.63, type: 'white' }, + { note: 'C#4', freq: 277.18, type: 'black' }, + { note: 'D4', freq: 293.66, type: 'white' }, + { note: 'D#4', freq: 311.13, type: 'black' }, + { note: 'E4', freq: 329.63, type: 'white' }, + { note: 'F4', freq: 349.23, type: 'white' }, + { note: 'F#4', freq: 369.99, type: 'black' }, + { note: 'G4', freq: 392.0, type: 'white' }, + { note: 'G#4', freq: 415.3, type: 'black' }, + { note: 'A4', freq: 440.0, type: 'white' }, + { note: 'A#4', freq: 466.16, type: 'black' }, + { note: 'B4', freq: 493.88, type: 'white' }, +] + +// Map QWERTY keys to notes +// Layout: +// W E T Y U O P +// A S D F G H J K L ; +export const QWERTY_MAP = { + // Bottom row - white keys (octave 3) + a: 'C3', + s: 'D3', + d: 'E3', + f: 'F3', + g: 'G3', + h: 'A3', + j: 'B3', + // Bottom row - white keys (octave 4) + k: 'C4', + l: 'D4', + ';': 'E4', + "'": 'F4', + // Top row - black keys (octave 3) + w: 'C#3', + e: 'D#3', + t: 'F#3', + y: 'G#3', + u: 'A#3', + // Top row - black keys (octave 4) + o: 'C#4', + p: 'D#4', + ']': 'F#4', + // Additional keys for remaining notes + z: 'G4', + x: 'A4', + c: 'B4', + '[': 'G#4', + '\\': 'A#4', +} + +// Reverse lookup: note -> QWERTY key (for displaying hints) +export const NOTE_TO_KEY = Object.fromEntries( + Object.entries(QWERTY_MAP).map(([key, note]) => [note, key]) +) + +// Get note data by note name +export const getNoteByName = (noteName) => NOTES.find((n) => n.note === noteName) + +// Available waveforms +export const WAVEFORMS = ['sawtooth', 'square', 'sine', 'triangle'] diff --git a/src/hooks/useArpeggiator.js b/src/hooks/useArpeggiator.js new file mode 100644 index 0000000..5eeb4c5 --- /dev/null +++ b/src/hooks/useArpeggiator.js @@ -0,0 +1,171 @@ +import { useState, useCallback, useRef } from 'react' +import { getNoteByName } from '../config/keyboard' + +/** + * Arpeggiator hook - cycles through held notes in a pattern + */ +export function useArpeggiator(synth) { + // 16-step gate pattern + const [pattern, setPattern] = useState(() => Array(16).fill(false)) + // Notes currently held (toggled on) + const [heldNotes, setHeldNotes] = useState(() => new Set()) + // Arpeggiator mode: 'up', 'down', 'up-down' + const [mode, setMode] = useState('up') + // Rate: '1/4', '1/8', '1/16' + const [rate, setRate] = useState('1/16') + + // Refs for scheduling (don't trigger re-renders) + const arpStepRef = useRef(0) + const lastPlayedNoteRef = useRef(null) + const directionRef = useRef(1) // 1 = up, -1 = down (for up-down mode) + const onNoteChangeRef = useRef(null) + + // Toggle a step in the gate pattern + const toggleStep = useCallback((index) => { + setPattern((prev) => + prev.map((val, i) => (i === index ? !val : val)) + ) + }, []) + + // Toggle a note on/off + const toggleNote = useCallback((noteName) => { + setHeldNotes((prev) => { + const next = new Set(prev) + if (next.has(noteName)) { + next.delete(noteName) + } else { + next.add(noteName) + } + return next + }) + }, []) + + // Clear all held notes + const clearNotes = useCallback(() => { + setHeldNotes(new Set()) + }, []) + + // Set callback for note change (for keyboard highlighting) + const setOnNoteChange = useCallback((callback) => { + onNoteChangeRef.current = callback + }, []) + + // Get sorted notes by frequency (low to high) + const getSortedNotes = useCallback(() => { + return [...heldNotes].sort((a, b) => { + const freqA = getNoteByName(a)?.freq || 0 + const freqB = getNoteByName(b)?.freq || 0 + return freqA - freqB + }) + }, [heldNotes]) + + // Calculate which note to play based on mode + const getNextNoteIndex = useCallback((sortedNotes) => { + const len = sortedNotes.length + if (len === 0) return -1 + + const step = arpStepRef.current + + if (mode === 'up') { + return step % len + } else if (mode === 'down') { + return (len - 1) - (step % len) + } else if (mode === 'up-down') { + if (len === 1) return 0 + // Ping-pong: 0,1,2,1,0,1,2,1... + const cycle = len * 2 - 2 + const pos = step % cycle + if (pos < len) { + return pos + } else { + return cycle - pos + } + } + return 0 + }, [mode]) + + // Schedule arp notes - called by sequencer + const scheduleArpNote = useCallback((currentStep, noteTime, stepTime) => { + // Check if gate is open for this step + if (!pattern[currentStep]) { + return + } + + // Rate determines how often we advance the arp + const stepsPerArpNote = { '1/4': 4, '1/8': 2, '1/16': 1 } + const skipSteps = stepsPerArpNote[rate] || 1 + + // Only play on matching steps + if (currentStep % skipSteps !== 0) { + return + } + + const sortedNotes = getSortedNotes() + if (sortedNotes.length === 0) return + + const noteIndex = getNextNoteIndex(sortedNotes) + if (noteIndex < 0) return + + const noteName = sortedNotes[noteIndex] + const noteData = getNoteByName(noteName) + if (!noteData) return + + // Stop previous note + if (lastPlayedNoteRef.current) { + synth.stopNote('arp-' + lastPlayedNoteRef.current) + } + + // Play new note + synth.playNote('arp-' + noteName, noteData.freq, noteTime) + lastPlayedNoteRef.current = noteName + + // Notify keyboard of current note + if (onNoteChangeRef.current) { + onNoteChangeRef.current(noteName) + } + + // Advance arp step + arpStepRef.current++ + }, [pattern, rate, getSortedNotes, getNextNoteIndex, synth]) + + // Stop current arp note (called when playback stops) + const stopArp = useCallback(() => { + if (lastPlayedNoteRef.current) { + synth.stopNote('arp-' + lastPlayedNoteRef.current) + lastPlayedNoteRef.current = null + } + arpStepRef.current = 0 + directionRef.current = 1 + if (onNoteChangeRef.current) { + onNoteChangeRef.current(null) + } + }, [synth]) + + // Reset pattern + const resetPattern = useCallback(() => { + setPattern(Array(16).fill(false)) + }, []) + + return { + // State + pattern, + heldNotes, + mode, + rate, + + // Actions + toggleStep, + toggleNote, + clearNotes, + setMode, + setRate, + resetPattern, + setOnNoteChange, + + // Scheduling + scheduleArpNote, + stopArp, + } +} + +export default useArpeggiator diff --git a/src/hooks/useAudioEngine.js b/src/hooks/useAudioEngine.js index 522a4e1..4f70dfb 100644 --- a/src/hooks/useAudioEngine.js +++ b/src/hooks/useAudioEngine.js @@ -2,13 +2,16 @@ import { useState, useRef, useCallback, useEffect } from 'react' /** * Custom hook for managing Web Audio API - * Handles loading sounds, playback with reverb and lowpass filter + * Handles loading sounds, playback, and per-track volume/effects */ export function useAudioEngine(instruments) { const [isLoading, setIsLoading] = useState(true) + const [analyser, setAnalyser] = useState(null) const audioContextRef = useRef(null) const audioBuffersRef = useRef({}) const effectNodesRef = useRef({}) + const masterGainRef = useRef(null) + const analyserRef = useRef(null) const initCountRef = useRef(0) // Initialize audio context and load all instrument samples @@ -25,58 +28,68 @@ export function useAudioEngine(instruments) { const ctx = new (window.AudioContext || window.webkitAudioContext)() audioContextRef.current = ctx + // Create master gain and analyser for visualization + const masterGain = ctx.createGain() + const analyserNode = ctx.createAnalyser() + analyserNode.fftSize = 256 + analyserNode.smoothingTimeConstant = 0.8 + + masterGain.connect(analyserNode) + analyserNode.connect(ctx.destination) + + masterGainRef.current = masterGain + analyserRef.current = analyserNode + setAnalyser(analyserNode) + // Create effect nodes for each instrument instruments.forEach(instrument => { const mainGain = ctx.createGain() + const lowpassFilter = ctx.createBiquadFilter() const reverbGain = ctx.createGain() + const delayNode = ctx.createDelay(1.0) + const delayFeedback = ctx.createGain() + const delayWetGain = ctx.createGain() const outputGain = ctx.createGain() - const lowpassFilter = ctx.createBiquadFilter() - - // Create delay-based reverb effect - const delay1 = ctx.createDelay(1.0) - const delay2 = ctx.createDelay(1.0) - const feedback = ctx.createGain() - // Set delay times for reverb-like effect - delay1.delayTime.value = 0.05 - delay2.delayTime.value = 0.08 - feedback.gain.value = 0.4 - - // Configure lowpass filter + // Initial settings lowpassFilter.type = 'lowpass' - lowpassFilter.frequency.value = 20000 // Start fully open + lowpassFilter.frequency.value = 20000 // Start fully open lowpassFilter.Q.value = 1 - - // Initial settings + delayNode.delayTime.value = 0 + delayFeedback.gain.value = 0 + delayWetGain.gain.value = 0 reverbGain.gain.value = 0 // Signal routing: // mainGain -> lowpassFilter -> outputGain (dry) - // mainGain -> delay1 -> delay2 -> reverbGain -> outputGain (wet) - // -> feedback -> delay1 (feedback loop) + // mainGain -> reverbGain -> outputGain + // mainGain -> delayNode -> delayFeedback -> delayNode (feedback loop) + // -> delayWetGain -> outputGain mainGain.connect(lowpassFilter) - lowpassFilter.connect(outputGain) - mainGain.connect(delay1) - delay1.connect(delay2) - delay2.connect(feedback) - feedback.connect(delay1) - delay2.connect(reverbGain) + lowpassFilter.connect(outputGain) // Dry signal with filter + mainGain.connect(reverbGain) reverbGain.connect(outputGain) - outputGain.connect(ctx.destination) + mainGain.connect(delayNode) + delayNode.connect(delayFeedback) + delayFeedback.connect(delayNode) + delayNode.connect(delayWetGain) + delayWetGain.connect(outputGain) + outputGain.connect(masterGain) // Route through master for visualization effectNodesRef.current[instrument.id] = { mainGain, + lowpassFilter, reverbGain, + delayNode, + delayFeedback, + delayWetGain, outputGain, - lowpassFilter, - delay1, - delay2, - feedback, } }) // Load all samples for (const instrument of instruments) { + // Check if this init is still current if (currentInit !== initCountRef.current) return try { @@ -86,14 +99,16 @@ export function useAudioEngine(instruments) { } const arrayBuffer = await response.arrayBuffer() + // Check again before decode if (currentInit !== initCountRef.current || ctx.state === 'closed') return audioBuffersRef.current[instrument.id] = await ctx.decodeAudioData(arrayBuffer) } catch (error) { - console.error(`Failed to load audio sample for ${instrument.id}:`, error) + // Swallow load errors to allow remaining samples to continue } } + // Only set loading false if this init is still current if (currentInit === initCountRef.current) { setIsLoading(false) } @@ -107,7 +122,6 @@ export function useAudioEngine(instruments) { initAudio() return () => { - initCountRef.current++ if (audioContextRef.current && audioContextRef.current.state !== 'closed') { audioContextRef.current.close() } @@ -118,34 +132,19 @@ export function useAudioEngine(instruments) { const playSound = useCallback((instrumentId, time, trackSettings) => { if (!audioBuffersRef.current[instrumentId] || !audioContextRef.current) return + // Check mute/solo if (trackSettings?.muted) return - const ctx = audioContextRef.current - const source = ctx.createBufferSource() + const source = audioContextRef.current.createBufferSource() source.buffer = audioBuffersRef.current[instrumentId] - const volume = trackSettings?.volume ?? 0.8 - const reverb = trackSettings?.reverb ?? 0 - const filter = trackSettings?.filter ?? 1 - + // Connect through effect chain const nodes = effectNodesRef.current[instrumentId] if (nodes) { - // Set reverb wet amount - nodes.reverbGain.gain.setValueAtTime(reverb, time) - - // Set filter cutoff (0 = 200Hz dark, 1 = 20000Hz bright) - // Using exponential mapping for more musical response - const minFreq = 200 - const maxFreq = 20000 - const cutoff = minFreq * Math.pow(maxFreq / minFreq, filter) - nodes.lowpassFilter.frequency.setValueAtTime(cutoff, time) - - // Set volume - nodes.mainGain.gain.setValueAtTime(volume, time) - + nodes.mainGain.gain.value = trackSettings?.volume ?? 0.8 source.connect(nodes.mainGain) } else { - source.connect(ctx.destination) + source.connect(audioContextRef.current.destination) } source.start(time) @@ -159,31 +158,37 @@ export function useAudioEngine(instruments) { } }, []) - // Set effect parameters in real-time for a specific track + // Set effect parameters for a track const setEffect = useCallback((instrumentId, effectType, value) => { const nodes = effectNodesRef.current[instrumentId] if (!nodes || !audioContextRef.current) return + const sliderValue = parseFloat(value) const currentTime = audioContextRef.current.currentTime - switch (effectType) { - case 'volume': - nodes.mainGain.gain.setValueAtTime(value, currentTime) - break - case 'reverb': - nodes.reverbGain.gain.setValueAtTime(value, currentTime) - break - case 'filter': { - const minFreq = 200 - const maxFreq = 20000 - const cutoff = minFreq * Math.pow(maxFreq / minFreq, value) - nodes.lowpassFilter.frequency.setValueAtTime(cutoff, currentTime) - break - } + if (effectType === 'reverb') { + const reverbAmount = sliderValue + nodes.reverbGain.gain.setValueAtTime(reverbAmount, currentTime) + } + + if (effectType === 'delay') { + const delayTime = sliderValue * 0.5 + const feedbackAmount = sliderValue * 0.7 + const wetAmount = sliderValue * 0.5 + nodes.delayNode.delayTime.setValueAtTime(delayTime, currentTime) + nodes.delayFeedback.gain.setValueAtTime(feedbackAmount, currentTime) + nodes.delayWetGain.gain.setValueAtTime(wetAmount, currentTime) + } + + if (effectType === 'filter') { + const minFreq = 200 + const maxFreq = 20000 + const cutoff = minFreq * Math.pow(maxFreq / minFreq, sliderValue) + nodes.lowpassFilter.frequency.setValueAtTime(cutoff, currentTime) } }, []) - // Resume audio context + // Resume audio context (needed for browser autoplay policies) const resumeContext = useCallback(async () => { if (audioContextRef.current?.state === 'suspended') { await audioContextRef.current.resume() @@ -201,50 +206,54 @@ export function useAudioEngine(instruments) { const ctx = audioContextRef.current try { + // Create effect nodes const mainGain = ctx.createGain() + const lowpassFilter = ctx.createBiquadFilter() const reverbGain = ctx.createGain() + const delayNode = ctx.createDelay(1.0) + const delayFeedback = ctx.createGain() + const delayWetGain = ctx.createGain() const outputGain = ctx.createGain() - const lowpassFilter = ctx.createBiquadFilter() - const delay1 = ctx.createDelay(1.0) - const delay2 = ctx.createDelay(1.0) - const feedback = ctx.createGain() - - delay1.delayTime.value = 0.05 - delay2.delayTime.value = 0.08 - feedback.gain.value = 0.4 - reverbGain.gain.value = 0 lowpassFilter.type = 'lowpass' - lowpassFilter.frequency.value = 20000 + lowpassFilter.frequency.value = 20000 // Start fully open lowpassFilter.Q.value = 1 + delayNode.delayTime.value = 0 + delayFeedback.gain.value = 0 + delayWetGain.gain.value = 0 + reverbGain.gain.value = 0 mainGain.connect(lowpassFilter) lowpassFilter.connect(outputGain) - mainGain.connect(delay1) - delay1.connect(delay2) - delay2.connect(feedback) - feedback.connect(delay1) - delay2.connect(reverbGain) + mainGain.connect(reverbGain) reverbGain.connect(outputGain) - outputGain.connect(ctx.destination) + mainGain.connect(delayNode) + delayNode.connect(delayFeedback) + delayFeedback.connect(delayNode) + delayNode.connect(delayWetGain) + delayWetGain.connect(outputGain) + outputGain.connect(masterGainRef.current || ctx.destination) effectNodesRef.current[instrument.id] = { - mainGain, reverbGain, outputGain, lowpassFilter, delay1, delay2, feedback, + mainGain, lowpassFilter, reverbGain, delayNode, delayFeedback, delayWetGain, outputGain, } + // Load sample const response = await fetch(instrument.path) const arrayBuffer = await response.arrayBuffer() audioBuffersRef.current[instrument.id] = await ctx.decodeAudioData(arrayBuffer) return true } catch (error) { - console.error(`Failed to load instrument ${instrument.id}:`, error) return false } }, []) return { isLoading, + analyser, + audioContext: audioContextRef.current, + masterGain: masterGainRef.current, playSound, setTrackVolume, setEffect, diff --git a/src/hooks/useSequencer.js b/src/hooks/useSequencer.js index 341d6f2..b08cfb6 100644 --- a/src/hooks/useSequencer.js +++ b/src/hooks/useSequencer.js @@ -5,9 +5,8 @@ import { decodeStateFromUrl, encodeStateToUrl } from '../utils/urlState' /** * Custom hook for managing sequencer state and playback timing */ -export function useSequencer(instruments, audioEngine) { - const { steps, bpm, scheduleAheadTime, schedulerInterval } = SEQUENCER_CONFIG - const stepTime = 60 / bpm / 4 // Time per 16th note +export function useSequencer(instruments, audioEngine, arpeggiator = null) { + const { steps, bpm: defaultBpm, scheduleAheadTime, schedulerInterval } = SEQUENCER_CONFIG // Initialize state from URL if available const [pattern, setPattern] = useState(() => { @@ -18,9 +17,16 @@ export function useSequencer(instruments, audioEngine) { const urlState = decodeStateFromUrl(instruments, steps) return urlState.trackSettings || createInitialTrackSettings(instruments) }) + const [bpm, setBpmState] = useState(() => { + const urlState = decodeStateFromUrl(instruments, steps) + return urlState.bpm || defaultBpm + }) const [isPlaying, setIsPlaying] = useState(false) const [currentStep, setCurrentStep] = useState(-1) + // Compute stepTime dynamically from bpm + const stepTime = 60 / bpm / 4 // Time per 16th note + // Refs for playback timing (don't trigger re-renders) const nextNoteTimeRef = useRef(0) const currentStepRef = useRef(0) @@ -30,6 +36,9 @@ export function useSequencer(instruments, audioEngine) { const isPlayingRef = useRef(false) const patternRef = useRef(pattern) const trackSettingsRef = useRef(trackSettings) + const bpmRef = useRef(bpm) + const stepTimeRef = useRef(stepTime) + const arpeggiatorRef = useRef(arpeggiator) // Keep refs in sync with state useEffect(() => { @@ -44,6 +53,15 @@ export function useSequencer(instruments, audioEngine) { trackSettingsRef.current = trackSettings }, [trackSettings]) + useEffect(() => { + bpmRef.current = bpm + stepTimeRef.current = 60 / bpm / 4 + }, [bpm]) + + useEffect(() => { + arpeggiatorRef.current = arpeggiator + }, [arpeggiator]) + // Check if any track is soloed const hasSoloedTrack = useCallback(() => { return Object.values(trackSettingsRef.current).some(s => s.solo) @@ -68,6 +86,7 @@ export function useSequencer(instruments, audioEngine) { while (nextNoteTimeRef.current < currentTime + scheduleAheadTime) { const step = currentStepRef.current + // Schedule drum sounds instruments.forEach(instrument => { if (patternRef.current[instrument.id][step] && shouldTrackPlay(instrument.id)) { const settings = trackSettingsRef.current[instrument.id] @@ -75,25 +94,31 @@ export function useSequencer(instruments, audioEngine) { } }) - nextNoteTimeRef.current += stepTime + // Schedule arpeggiator notes + if (arpeggiatorRef.current) { + arpeggiatorRef.current.scheduleArpNote(step, nextNoteTimeRef.current, stepTimeRef.current) + } + + nextNoteTimeRef.current += stepTimeRef.current currentStepRef.current = (currentStepRef.current + 1) % steps } timerIdRef.current = setTimeout(scheduler, schedulerInterval) - }, [instruments, audioEngine, steps, stepTime, scheduleAheadTime, schedulerInterval, shouldTrackPlay]) + }, [instruments, audioEngine, steps, scheduleAheadTime, schedulerInterval, shouldTrackPlay]) // Visual update loop - syncs UI with audio const updateVisuals = useCallback(() => { if (!isPlayingRef.current) return const currentTime = audioEngine.getCurrentTime() - const loopDuration = steps * stepTime + const currentStepTime = stepTimeRef.current + const loopDuration = steps * currentStepTime const timeWithinLoop = (currentTime - startTimeRef.current) % loopDuration - const visualStep = Math.floor(timeWithinLoop / stepTime) + const visualStep = Math.floor(timeWithinLoop / currentStepTime) setCurrentStep(visualStep) animationFrameRef.current = requestAnimationFrame(updateVisuals) - }, [audioEngine, steps, stepTime]) + }, [audioEngine, steps]) // Start playback const startPlayback = useCallback(async () => { @@ -125,6 +150,11 @@ export function useSequencer(instruments, audioEngine) { cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = null } + + // Stop arpeggiator + if (arpeggiatorRef.current) { + arpeggiatorRef.current.stopArp() + } }, []) // Toggle a step in the pattern @@ -141,6 +171,10 @@ export function useSequencer(instruments, audioEngine) { const resetPattern = useCallback(() => { stopPlayback() setPattern(createInitialPattern(instruments, steps)) + // Also reset arpeggiator pattern + if (arpeggiatorRef.current) { + arpeggiatorRef.current.resetPattern() + } }, [stopPlayback, instruments, steps]) // Update track settings (volume, mute, solo) @@ -166,10 +200,16 @@ export function useSequencer(instruments, audioEngine) { })) }, [steps]) - // Persist state to URL when pattern or trackSettings changes + // Update BPM with clamping + const setBpm = useCallback((newBpm) => { + const clampedBpm = Math.max(60, Math.min(200, Number(newBpm) || 120)) + setBpmState(clampedBpm) + }, []) + + // Persist state to URL when pattern, trackSettings, or bpm changes useEffect(() => { - encodeStateToUrl(pattern, trackSettings, instruments) - }, [pattern, trackSettings, instruments]) + encodeStateToUrl(pattern, trackSettings, instruments, bpm) + }, [pattern, trackSettings, instruments, bpm]) // Cleanup on unmount useEffect(() => { @@ -186,6 +226,7 @@ export function useSequencer(instruments, audioEngine) { currentStep, steps, bpm, + setBpm, toggleStep, startPlayback, stopPlayback, diff --git a/src/hooks/useSynthEngine.js b/src/hooks/useSynthEngine.js new file mode 100644 index 0000000..5470b38 --- /dev/null +++ b/src/hooks/useSynthEngine.js @@ -0,0 +1,198 @@ +import { useState, useRef, useCallback, useEffect } from 'react' + +/** + * Custom hook for oscillator-based synthesis + * Manages note playback with ADSR envelope and waveform selection + * @param {AudioContext} sharedContext - Optional shared AudioContext from drum engine + * @param {GainNode} sharedMasterGain - Optional shared master gain node for routing audio + */ +export function useSynthEngine(sharedContext = null, sharedMasterGain = null) { + const [waveform, setWaveformState] = useState('sawtooth') + const [volume, setVolumeState] = useState(0.5) + + const audioContextRef = useRef(null) + const masterGainRef = useRef(null) + const limiterRef = useRef(null) + const activeNotesRef = useRef(new Map()) // noteId -> { oscillator, gainNode } + const waveformRef = useRef('sawtooth') + const volumeRef = useRef(0.5) + const isUsingSharedContext = useRef(false) + + // Keep refs in sync with state + useEffect(() => { + waveformRef.current = waveform + }, [waveform]) + + useEffect(() => { + volumeRef.current = volume + if (masterGainRef.current && audioContextRef.current) { + masterGainRef.current.gain.setValueAtTime( + volume, + audioContextRef.current.currentTime + ) + } + }, [volume]) + + // Initialize audio context lazily (on first user interaction) + const ensureContext = useCallback(() => { + if (audioContextRef.current) return audioContextRef.current + + // Use shared context if provided, otherwise create our own + const ctx = sharedContext || new (window.AudioContext || window.webkitAudioContext)() + audioContextRef.current = ctx + isUsingSharedContext.current = !!sharedContext + + // Create master gain + const masterGain = ctx.createGain() + masterGain.gain.value = volumeRef.current + masterGainRef.current = masterGain + + if (sharedContext && sharedMasterGain) { + // If using shared context, connect our master gain to the shared master gain + // This routes synth audio through the same analyser as drums + masterGain.connect(sharedMasterGain) + } else { + // Create limiter to prevent clipping (only for standalone mode) + const limiter = ctx.createDynamicsCompressor() + limiter.threshold.value = -3 + limiter.knee.value = 0 + limiter.ratio.value = 20 + limiter.attack.value = 0.001 + limiter.release.value = 0.1 + limiterRef.current = limiter + + // Connect: masterGain -> limiter -> destination + masterGain.connect(limiter) + limiter.connect(ctx.destination) + } + + return ctx + }, [sharedContext, sharedMasterGain]) + + // Resume context if suspended (browser autoplay policy) + const resumeContext = useCallback(async () => { + const ctx = ensureContext() + if (ctx.state === 'suspended') { + await ctx.resume() + } + }, [ensureContext]) + + // ADSR envelope parameters + const envelope = { + attack: 0.01, // 10ms + decay: 0.1, // 100ms + sustain: 0.7, // 70% of max + release: 0.2, // 200ms + } + + // Play a note by frequency + const playNote = useCallback( + (noteId, frequency, time) => { + const ctx = ensureContext() + if (ctx.state === 'suspended') { + ctx.resume() + } + + // Don't play if already playing + if (activeNotesRef.current.has(noteId)) return + + const now = time ?? ctx.currentTime + + // Create oscillator + const oscillator = ctx.createOscillator() + oscillator.type = waveformRef.current + oscillator.frequency.setValueAtTime(frequency, now) + + // Create gain for envelope + const gainNode = ctx.createGain() + gainNode.gain.setValueAtTime(0, now) + + // Attack + gainNode.gain.linearRampToValueAtTime(1, now + envelope.attack) + // Decay to sustain level + gainNode.gain.linearRampToValueAtTime( + envelope.sustain, + now + envelope.attack + envelope.decay + ) + + // Connect: oscillator -> gainNode -> masterGain + oscillator.connect(gainNode) + gainNode.connect(masterGainRef.current) + + // Start oscillator + oscillator.start(now) + + // Store reference for stopping later + activeNotesRef.current.set(noteId, { oscillator, gainNode }) + }, + [ensureContext] + ) + + // Stop a note + const stopNote = useCallback((noteId) => { + const noteData = activeNotesRef.current.get(noteId) + if (!noteData) return + + const { oscillator, gainNode } = noteData + const ctx = audioContextRef.current + if (!ctx) return + + const now = ctx.currentTime + + // Release envelope + gainNode.gain.cancelScheduledValues(now) + gainNode.gain.setValueAtTime(gainNode.gain.value, now) + gainNode.gain.linearRampToValueAtTime(0, now + envelope.release) + + // Stop oscillator after release + oscillator.stop(now + envelope.release + 0.01) + + // Remove from active notes + activeNotesRef.current.delete(noteId) + }, []) + + // Stop all notes + const stopAllNotes = useCallback(() => { + for (const noteId of activeNotesRef.current.keys()) { + stopNote(noteId) + } + }, [stopNote]) + + // Set waveform type + const setWaveform = useCallback((type) => { + setWaveformState(type) + // Update any currently playing oscillators + for (const { oscillator } of activeNotesRef.current.values()) { + oscillator.type = type + } + }, []) + + // Set master volume + const setVolume = useCallback((value) => { + setVolumeState(value) + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => { + stopAllNotes() + // Only close context if we created it ourselves (not shared) + if (!isUsingSharedContext.current && audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.close() + } + } + }, [stopAllNotes]) + + return { + playNote, + stopNote, + stopAllNotes, + setWaveform, + setVolume, + waveform, + volume, + resumeContext, + } +} + +export default useSynthEngine diff --git a/src/index.css b/src/index.css index d99e09e..3729cf8 100644 --- a/src/index.css +++ b/src/index.css @@ -31,6 +31,8 @@ body { /* --- MAIN DRUM MACHINE CONTAINER --- */ .container, .drum-machine { + display: flex; + flex-direction: column; background: linear-gradient(145deg, #e8e4dc 0%, #d4d0c8 50%, #c8c4bc 100%); border-radius: 18px; padding: 38px 45px 30px 45px; @@ -40,7 +42,7 @@ body { inset 0 1px 0 rgba(255, 255, 255, 0.5), inset 0 -1px 0 rgba(0, 0, 0, 0.1); position: relative; - min-width: 1080px; + width: fit-content; } /* Subtle texture overlay */ @@ -60,6 +62,8 @@ body { /* --- HEADER SECTION --- */ .header { + display: flex; + align-items: center; margin-bottom: 30px; padding: 0 8px; } @@ -77,7 +81,9 @@ body { display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 6px; + flex: 1; } .tempo-label { @@ -95,7 +101,6 @@ body { box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2), 0 1px 0 rgba(255, 255, 255, 0.1); - text-align: center; } #tempo-value { @@ -104,6 +109,23 @@ body { color: #1a1a1a; letter-spacing: 2px; font-weight: bold; + background: transparent; + border: none; + width: 3.5ch; + text-align: center; + outline: none; + -moz-appearance: textfield; +} + +#tempo-value::-webkit-outer-spin-button, +#tempo-value::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +#tempo-value:focus { + background: rgba(0, 0, 0, 0.05); + border-radius: 2px; } /* --- SEQUENCER SECTION --- */ @@ -113,67 +135,110 @@ body { gap: 6px; } -/* Step numbers rows */ -.step-numbers-top, -.step-numbers-bottom { +/* --- DRUMS SECTION --- */ +.drums-row { display: flex; - align-items: center; + align-items: flex-start; + gap: 12px; } -.instrument-label-spacer { - width: 105px; - flex-shrink: 0; +.drums-grid-container { + display: flex; + flex-direction: column; + gap: 10px; + background: linear-gradient(180deg, #d8d4cc 0%, #c8c4bc 100%); + border-radius: 8px; + padding: 12px; + border: 1px solid rgba(0, 0, 0, 0.15); + width: fit-content; } -.controls-spacer { - width: 240px; +.track-controls-column { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + /* top padding: match drums container border (1px) + padding (12px) + step numbers height (20px) + gap (10px) */ + padding-top: 43px; + background: linear-gradient(180deg, #d8d4cc 0%, #c8c4bc 100%); + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.15); flex-shrink: 0; + align-self: flex-start; + width: 156px; /* align right edge with transport and visualizer */ + box-sizing: border-box; } -.slider-labels { +/* Step numbers row */ +.step-numbers-row { display: flex; - gap: 12px; - margin-left: 15px; align-items: center; } -.slider-label { - font-size: 0.9em; - font-weight: 600; - color: #444; - text-align: center; - width: 40px; +.instrument-label-spacer, +.label-spacer { + width: 90px; + height: 20px; /* match step number height */ + flex-shrink: 0; +} + + +.controls-spacer { + width: 240px; + flex-shrink: 0; } .step-numbers { display: flex; gap: 6px; + padding-left: 68px; /* align with pads: track-label width (60px) + gap (8px) */ +} + +/* Step number groups to match pad groups */ +.step-number-group { + display: flex; + gap: 6px; +} + +.step-number-group.bordered { + padding: 3px; + margin: -3px; } .step-numbers span { width: 36px; + height: 20px; text-align: center; font-size: 1.05em; font-weight: 600; color: #444; } -/* --- INSTRUMENT TRACKS --- */ -.instrument-tracks { - display: flex; - flex-direction: column; - gap: 12px; +/* --- ARPEGGIATOR TRACK --- */ +.arp-notes { + padding-left: 6px; /* align with drum pads */ } -.instrument-track-row { - display: flex; - align-items: center; - gap: 15px; +/* Arp pads - different color scheme */ +.arp-notes .note-button.active { + background: linear-gradient(180deg, #27ae60 0%, #1e8449 100%); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.4), + inset 0 -1px 0 rgba(0, 0, 0, 0.2), + 0 0 12px rgba(39, 174, 96, 0.4); +} + +.arp-notes .note-button.active:hover { + background: linear-gradient(180deg, #2ecc71 0%, #27ae60 100%); } /* Instrument labels */ .instrument-label { width: 90px; + height: 42px; /* match pad height */ + display: flex; + align-items: center; font-size: 1.125em; font-weight: 700; color: #222; @@ -181,10 +246,28 @@ body { letter-spacing: 0.75px; } +/* Track row with label */ +.track-row { + display: flex; + align-items: center; + gap: 8px; +} + +.track-label { + width: 60px; + font-size: 0.7em; + font-weight: 700; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: right; + flex-shrink: 0; +} + /* Notes container */ .notes-container { display: flex; - gap: 12px; + gap: 6px; } /* Pad groups (4 pads each) */ @@ -204,9 +287,9 @@ body { .track-controls { display: flex; align-items: center; - justify-content: space-between; - margin-left: 15px; - width: 160px; + justify-content: center; + gap: 8px; + height: 42px; /* match instrument row height */ } /* --- KNOB CONTROLS --- */ @@ -247,6 +330,83 @@ body { letter-spacing: 0.5px; } +/* --- FADER CONTROLS --- */ +.fader-container { + display: flex; + flex-direction: column; + align-items: center; +} + +.fader-wrapper { + position: relative; + width: 60px; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; +} + +/* Single horizontal tick lines above and below track */ +.fader-tick-line { + width: 100%; + height: 2px; + background: #998b7a; + border-radius: 1px; +} + +/* Fader track - horizontal bar */ +.fader-track { + position: relative; + width: 100%; + height: 10px; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + border-radius: 5px; + cursor: pointer; + box-shadow: + inset 0 1px 3px rgba(0, 0, 0, 0.5), + 0 1px 0 rgba(255, 255, 255, 0.05); +} + +/* Fader thumb - the draggable handle */ +.fader-thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 24px; + height: 24px; + cursor: grab; + z-index: 2; +} + +.fader-thumb:active { + cursor: grabbing; +} + +/* The round grip part of the thumb */ +.fader-thumb-grip { + width: 100%; + height: 100%; + background: linear-gradient(180deg, #5a5a5a 0%, #3a3a3a 50%, #2a2a2a 100%); + border-radius: 50%; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.15), + inset 0 -1px 0 rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* Orange indicator stripe on the thumb */ +.fader-thumb-indicator { + width: 4px; + height: 14px; + background: linear-gradient(180deg, #f5a623 0%, #e08a00 100%); + border-radius: 2px; + box-shadow: 0 0 4px rgba(245, 166, 35, 0.4); +} + .track-button { width: 36px; height: 30px; @@ -353,116 +513,340 @@ body { height: 24px; } -/* --- BOTTOM ROW --- */ -.bottom-row { +/* --- SYNTH SECTION (vol + steps + keyboard + transport) --- */ +.synth-section { display: flex; - align-items: flex-end; + flex-direction: column; + gap: 16px; margin-top: 24px; - gap: 15px; } -.keyboard-wrapper { - flex: 1; +.synth-row { display: flex; - justify-content: center; - min-width: 0; + align-items: stretch; + gap: 12px; } -/* --- TRANSPORT CONTROLS --- */ -.transport-controls { +.synth-vol-container { display: flex; - flex-direction: column; align-items: stretch; + flex-shrink: 0; +} + +.synth-grid-container { + display: flex; + flex-direction: column; gap: 12px; - padding: 16px; background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); border-radius: 8px; - box-shadow: - inset 0 2px 6px rgba(0, 0, 0, 0.4), - 0 1px 0 rgba(255, 255, 255, 0.1); + padding: 12px; + width: fit-content; } -.transport-label { - font-size: 0.75em; - font-weight: 600; - color: #a0a0a0; - letter-spacing: 1.5px; - text-align: center; +/* Transport section */ +.transport-section { + display: flex; + align-items: stretch; + flex-shrink: 0; + width: 156px; /* align right edge with knobs and visualizer */ } -.transport-display { - background: #c5c8b8; - padding: 10px 20px; - border-radius: 4px; - border: 3px solid #1a1a1a; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); - text-align: center; +/* Synth controls row (direction, speed, wave) */ +.synth-controls-row { + display: flex; + align-items: center; + gap: 12px; } -.transport-value { - font-family: "Courier New", monospace; - font-size: 1.75em; - color: #1a1a1a; - letter-spacing: 2px; - font-weight: bold; +.synth-controls-center { + flex: 1; + display: flex; + justify-content: center; } -.transport-buttons { +.synth-controls-container { display: flex; - gap: 8px; + gap: 16px; } -.transport-button { +/* Visualizer container - takes remaining space */ +.visualizer-container { flex: 1; - padding: 10px 16px; - font-size: 0.85em; + display: flex; + align-items: center; +} + +/* Right visualizer - match transport/knobs width */ +.visualizer-container:last-child { + flex: none; + width: 156px; +} + +/* Audio visualizer canvas wrapper */ +.audio-visualizer { + width: 100%; + height: 60px; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + border-radius: 6px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4); + overflow: hidden; +} + +.audio-visualizer canvas { + width: 100%; + height: 100%; +} + +.transport-controls { + display: flex; + flex-direction: column; + gap: 10px; + background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + padding: 14px 16px; + border-radius: 8px; + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.4), + 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.transport-buttons { + display: flex; + gap: 6px; +} + +/* --- BUTTON BASE --- */ +.btn { font-weight: 700; text-transform: uppercase; letter-spacing: 1px; - border-radius: 6px; + border-radius: 4px; cursor: pointer; transition: all 0.1s ease; background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%); - border: 2px solid #1a1a1a; + border: 1px solid #1a1a1a; color: #e0e0e0; box-shadow: - 0 4px 8px rgba(0, 0, 0, 0.4), + 0 2px 4px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1); } -.transport-button:hover { +.btn:hover { background: linear-gradient(180deg, #5a5a5a 0%, #3a3a3a 100%); } -.transport-button:active, -.transport-button.active { +.btn:active, +.btn.active { background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); box-shadow: - inset 0 3px 6px rgba(0, 0, 0, 0.4), + inset 0 2px 4px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); } -.transport-button:disabled { +.btn:disabled { background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + border-color: #1a1a1a; color: #555; cursor: not-allowed; } -/* --- KEYBOARD SECTION --- */ -.keyboard-section { - padding: 10px 12px; +/* --- BUTTON VARIANTS --- */ +.btn-transport { + flex: 1; + padding: 8px 6px; + font-size: 0.85em; +} + +.btn-reset { + background: linear-gradient(180deg, #fafafa 0%, #e0e0e0 100%); + border: 1px solid #bbb; + color: #333; +} + +.btn-reset:hover { + background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%); +} + +.btn-reset:active { + background: linear-gradient(180deg, #e0e0e0 0%, #d0d0d0 100%); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.btn-play { + background: linear-gradient(180deg, #e74c3c 0%, #c0392b 100%); + border: 1px solid #a93226; + color: #fff; + min-width: 52px; +} + +.btn-play:hover { + background: linear-gradient(180deg, #ec7063 0%, #e74c3c 100%); +} + +.btn-play:active, +.btn-play.active { + background: linear-gradient(180deg, #c0392b 0%, #a93226 100%); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* --- KEYBOARD CONTROLS (dir, speed, vol, wave + keyboard) --- */ +.keyboard-controls { + display: flex; + align-items: stretch; + gap: 12px; +} + +/* Container for all synth controls */ +.synth-controls-container { + display: flex; + align-items: stretch; + gap: 8px; + padding: 10px; background: linear-gradient(180deg, #5a5a5a 0%, #3a3a3a 100%); - border-radius: 6px; + border-radius: 8px; box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3), 0 1px 0 rgba(255, 255, 255, 0.1); } +/* Stack for DIR over SPEED */ +.control-stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Half-height control boxes (for stacked DIR/SPEED) */ +.control-box-half { + flex: 1; + justify-content: center; +} + +/* --- CONTROL BOX (container for each control group) --- */ +.control-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: linear-gradient(180deg, #d8d4cc 0%, #c8c4bc 100%); + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.15); +} + +/* Vertical control boxes (VOL, WAVE) */ +.control-box-vertical { + padding: 8px 8px; + justify-content: space-between; +} + +.control-box-label { + font-size: 9px; + font-weight: 700; + color: #555; + letter-spacing: 1px; + text-transform: uppercase; +} + +.control-box-buttons { + display: flex; + gap: 3px; +} + +/* Vertical buttons layout */ +.control-box-buttons-vertical { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + justify-content: space-between; +} + +.control-btn { + width: 32px; + height: 26px; + font-size: 11px; + font-weight: 700; + border: 1px solid #444; + border-radius: 4px; + cursor: pointer; + transition: all 0.1s ease; + background: linear-gradient(180deg, #5a5a5a 0%, #3a3a3a 100%); + color: #aaa; + display: flex; + align-items: center; + justify-content: center; +} + +.control-btn:hover { + background: linear-gradient(180deg, #6a6a6a 0%, #4a4a4a 100%); + color: #ccc; +} + +.control-btn.active { + background: linear-gradient(180deg, #f5a623 0%, #e08a00 100%); + border-color: #f5a623; + color: #fff; + box-shadow: 0 0 8px rgba(245, 166, 35, 0.4); +} + +/* Wave buttons for icons */ +.control-btn.wave-btn { + width: 32px; + height: 26px; +} + +/* Vertical volume slider */ +.control-box-slider-vertical { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + width: 24px; +} + +.vol-slider-vertical { + width: 100px; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + border-radius: 3px; + cursor: pointer; + transform: rotate(-90deg); + transform-origin: center center; +} + +.vol-slider-vertical::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: linear-gradient(180deg, #f5a623 0%, #e08a00 100%); + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.vol-slider-vertical::-moz-range-thumb { + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: linear-gradient(180deg, #f5a623 0%, #e08a00 100%); + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* --- KEYBOARD SECTION --- */ +.keyboard-section { + display: flex; + justify-content: flex-start; + padding-left: 6px; /* align with drum pads */ +} + .keyboard { display: flex; position: relative; height: 150px; - justify-content: center; } .key { @@ -510,12 +894,78 @@ body { background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%); } -.key.black:active { +.key.black:active, +.key.black.active, +.key.black.held { background: linear-gradient(180deg, #2a2a2a 0%, #0a0a0a 100%); height: 87px; margin-top: 3px; } +/* Held state for white keys (toggled on for arpeggiator) */ +.key.white.held { + background: linear-gradient(180deg, #f5a623 0%, #e08a00 100%); + box-shadow: + 0 6px 12px rgba(0, 0, 0, 0.2), + inset 0 -6px 12px rgba(0, 0, 0, 0.1), + 0 0 12px rgba(245, 166, 35, 0.4); +} + +/* Held state for black keys */ +.key.black.held { + background: linear-gradient(180deg, #f5a623 0%, #c07000 100%); + box-shadow: + 0 6px 12px rgba(0, 0, 0, 0.4), + inset 0 -3px 6px rgba(0, 0, 0, 0.2), + 0 0 8px rgba(245, 166, 35, 0.5); + height: 90px; + margin-top: 0; +} + +/* Playing state - currently playing arp note */ +.key.white.playing { + background: linear-gradient(180deg, #fff 0%, #f5a623 100%); + box-shadow: + 0 6px 12px rgba(0, 0, 0, 0.2), + 0 0 20px rgba(245, 166, 35, 0.6); +} + +.key.black.playing { + background: linear-gradient(180deg, #f5a623 0%, #ff8c00 100%); + box-shadow: + 0 6px 12px rgba(0, 0, 0, 0.4), + 0 0 16px rgba(245, 166, 35, 0.7); +} + +/* Key hints (QWERTY letters) */ +.key-hint { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + opacity: 0.5; + pointer-events: none; + user-select: none; +} + +.key.white .key-hint { + color: #666; +} + +.key.black .key-hint { + color: #888; + bottom: 4px; +} + +/* Position keys relatively for hint positioning */ +.key { + position: relative; +} + + /* --- LOADING OVERLAY --- */ .loading-overlay { position: absolute; @@ -566,6 +1016,7 @@ body { .app-container { display: flex; justify-content: center; + width: fit-content; } @theme inline { diff --git a/src/test/setup.js b/src/test/setup.js new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/test/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/src/utils/urlState.js b/src/utils/urlState.js index 2c48bfc..a5f3b4d 100644 --- a/src/utils/urlState.js +++ b/src/utils/urlState.js @@ -36,11 +36,12 @@ function hexToFloat(hex, defaultValue = 0.8) { /** * Encode current state to URL - * Format: notes_settings + * Format: notes_settings_bpm * - notes: 4 hex chars per instrument (16 steps) - * - settings: 6 hex chars per instrument (volume 2, attack 2, decay 2) + * - settings: 6 hex chars per instrument (volume 2, reverb 2, filter 2) + * - bpm: 3 digit number */ -export function encodeStateToUrl(pattern, trackSettings, instruments) { +export function encodeStateToUrl(pattern, trackSettings, instruments, bpm = 120) { let notesHex = '' let settingsHex = '' @@ -54,7 +55,9 @@ export function encodeStateToUrl(pattern, trackSettings, instruments) { settingsHex += floatToHex(settings.filter ?? 1) }) - const compactState = `${notesHex}_${settingsHex}` + // BPM as 3-digit string (zero-padded) + const bpmStr = String(bpm).padStart(3, '0') + const compactState = `${notesHex}_${settingsHex}_${bpmStr}` const newUrl = window.location.pathname + '?s=' + compactState window.history.replaceState(null, '', newUrl) } @@ -68,15 +71,19 @@ export function decodeStateFromUrl(instruments, steps) { const result = { pattern: null, trackSettings: null, + bpm: null, } const compactState = params.get('s') - // Expected: 4 hex per instrument for notes + _ + 6 hex per instrument for settings - const expectedLength = instruments.length * 4 + 1 + instruments.length * 6 - if (compactState && compactState.length === expectedLength && compactState.includes('_')) { + // Support both old format (notes_settings) and new format (notes_settings_bpm) + if (compactState && compactState.includes('_')) { try { - const [notesPart, settingsPart] = compactState.split('_') + const parts = compactState.split('_') + const notesPart = parts[0] + const settingsPart = parts[1] + const bpmPart = parts[2] // may be undefined for old URLs + const pattern = {} const trackSettings = {} let noteOffset = 0 @@ -104,6 +111,14 @@ export function decodeStateFromUrl(instruments, steps) { result.pattern = pattern result.trackSettings = trackSettings + + // Parse BPM if present + if (bpmPart && bpmPart.length === 3) { + const parsedBpm = parseInt(bpmPart, 10) + if (parsedBpm >= 60 && parsedBpm <= 200) { + result.bpm = parsedBpm + } + } } catch (error) { console.error('Error parsing URL state:', error) } diff --git a/vite.config.js b/vite.config.js index b608676..ea26c84 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,4 +10,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.js', + }, })