diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0b7db38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,15 @@ +## Bug + +## Steps to reproduce + +## Expected behavior + +## Actual behavior + +## Relevant files or docs + +## Done when + +## Verification + +## Notes diff --git a/.github/ISSUE_TEMPLATE/feature_task.md b/.github/ISSUE_TEMPLATE/feature_task.md new file mode 100644 index 0000000..959f7dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_task.md @@ -0,0 +1,22 @@ +## Feature Spec +Related feature document: + +## Goal + +## Context + +## Scope + +Included: +- + +Excluded: +- + +## Constraints + +## Done when + +## Verification + +## Notes diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..af875e2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + +## Changes + +## Testing +- [ ] `npm run typecheck` +- [ ] `npm run lint` +- [ ] `npm run test` +- [ ] `npm run build` + +## Screenshots +If UI changed, add screenshots or a short recording. + +## Notes / Limitations + +## Follow-up diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5687214 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: | + if [ -f package-lock.json ]; then + npm ci + elif [ -f package.json ]; then + npm install + else + echo "No package.json yet; skipping install." + fi + + - name: Type check + run: | + if [ -f package.json ]; then + npm run typecheck --if-present + else + echo "No package.json yet; skipping typecheck." + fi + + - name: Lint + run: | + if [ -f package.json ]; then + npm run lint --if-present + else + echo "No package.json yet; skipping lint." + fi + + - name: Test + run: | + if [ -f package.json ]; then + npm run test --if-present + else + echo "No package.json yet; skipping test." + fi + + - name: Build + run: | + if [ -f package.json ]; then + npm run build --if-present + else + echo "No package.json yet; skipping build." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66ddd0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +dist +dist-ssr +coverage +.vite +.env +.env.* +!.env.example +.DS_Store +*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d876c37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md + +## Project Overview + +This repository is for a browser-based mini DAW built with Vite, React, TypeScript, CSS Modules, and the Web Audio API. + +The product is a clip-based electronic music creation tool. The first major milestone is a 1-bar hybrid clip editor with drum step sequencing, basic piano roll editing, and loop playback. Future work may add arrangement editing and desktop packaging with Electron or Tauri, but the initial target is a browser app. + +## Repository Layout + +Expected source layout after the scaffold task: + +- `src/app/`: app shell, providers, root composition. +- `src/components/`: shared reusable React components. +- `src/features/`: feature-specific UI and orchestration. +- `src/audio/`: Web Audio engine, transport, scheduling, sample playback. +- `src/model/`: serializable project types and model transformations. +- `src/persistence/`: import, export, storage, and migration helpers. +- `src/utils/`: pure utilities such as tick/time conversion. +- `src/styles/`: global CSS, tokens, and shared style primitives. +- `tests/unit/`: unit tests for utilities, model transformations, scheduler calculations, and other isolated logic. +- `tests/integration/`: integration tests for multi-module flows when needed. +- `docs/`: product, architecture, data model, audio, UI, testing, and feature specs. + +Preserve the existing structure unless a refactor is clearly required by the task. + +## Commands + +Use npm unless the repository already clearly uses another package manager. + +Intended project commands: + +```text +Install: npm install +Dev: npm run dev +Type check: npm run typecheck +Lint: npm run lint +Test: npm run test +Build: npm run build +``` + +If these scripts do not exist yet, future scaffold work must add them. CI uses `--if-present` until the Vite scaffold exists. + +## Engineering Conventions + +- Prefer small, focused, reviewable changes. +- Keep React UI separate from audio scheduling logic. +- Do not mix persistence, UI rendering, and audio scheduling in the same module. +- Keep project data serializable. +- Store musical time in ticks, not seconds. +- Use TypeScript types for project data, audio engine APIs, and feature boundaries. +- Follow `docs/code-conventions.md` for naming, file organization, TypeScript, React, CSS Modules, design tokens, domain naming, tests, and comments. +- Prefer pure utilities for tick math, model transformations, and scheduler calculations. +- Do not introduce new production dependencies without a clear reason. +- Do not rewrite large files unless required. +- Do not implement unrelated features while working on a feature spec. +- Do not change the project data model without updating `docs/data-model.md`. + +## Audio Rules + +- React UI must not own exact audio timing. +- Schedule audio against `AudioContext.currentTime`. +- Use a lookahead scheduler for sequenced playback. +- UI cursors and playheads may use `requestAnimationFrame`, but not for exact audio scheduling. +- Store musical time in ticks. The documented default PPQ is 480. +- Do not store `AudioBuffer` objects directly in project JSON. +- Runtime audio objects such as `AudioContext`, `AudioBuffer`, nodes, and decoded samples belong in audio runtime caches, not serializable project state. +- Create a new `AudioBufferSourceNode` for each repeated one-shot sample playback. +- The audio engine should expose a small typed API to the UI. + +## CSS Modules Rules + +- Use CSS Modules for component styles. +- Keep global CSS limited to resets, base document styles, and design tokens. +- Use CSS custom properties for shared colors, spacing, typography, and timing values. +- Define primitive design tokens first, then define semantic tokens from those primitives. +- Component CSS Modules should use semantic tokens. Do not reference primitive color, spacing, typography, or radius tokens directly in component styles unless there is a documented exception. +- Dynamic editor geometry such as note positions, note widths, and grid coordinates may use inline styles when values are computed at runtime. +- Do not add a visual design system dependency at this stage unless a task explicitly justifies it. + +## UI Reference Policy + +- If a design prototype uses Tailwind, inline styles, or CDN assets, treat it as visual reference only. +- Convert visual patterns into CSS Modules and shared CSS variables. +- Do not add Tailwind or CDN-based styling unless explicitly requested in a feature spec. +- Inline styles are acceptable for dynamic editor geometry such as note positions, widths, grid coordinates, and velocity heights. + +## Testing Rules + +- Keep production code under `src/`; place unit tests under `tests/unit/`. +- Add integration tests under `tests/integration/` only when a task needs multi-module workflow coverage. +- Do not add an end-to-end test framework or `tests/e2e/` until a feature spec calls for browser flow testing. +- Keep `tsconfig.test.json` in sync with test locations so `npm run typecheck` checks test files. +- Do not remove tests or checks to make a task pass. + +## Issue Workflow + +- Use GitHub Issues as the task queue for feature work, bugs, and larger refactors. +- Follow `PLANS.md` `Active Issue Order` when choosing the next issue unless the user gives a different priority. +- Prefer one issue per feature spec or focused bug. +- Link issues to feature documents under `docs/features/` when applicable. +- Use branch names that include the issue number when possible: + - `feat/-` + - `fix/-` + - `docs/-` +- PRs should reference the issue they address. +- Use `Closes #` only when the PR fully completes the issue. +- Do not close issues unless the acceptance criteria are met. +- Do not create duplicate issues. Search existing open issues first when possible. +- Do not create issues for tiny typo fixes or trivial cleanup unless requested. + +## Git and PR Expectations + +- Work on a focused branch when possible. +- Open PRs against `develop` by default unless a maintainer explicitly requests another base branch. +- Keep commits scoped to the task. +- Use conventional commit messages when practical. +- PR summaries should explain what changed, how it was tested, and known limitations. +- Do not remove tests or checks to make a task pass. +- Do not merge PRs automatically. + +## Constraints and Do-Not Rules + +- Do not implement production DAW features unless the active task asks for them. +- Do not add sample files unless the active task asks for them. +- Do not add `CHANGELOG.md` for now. +- Do not store runtime-only audio objects in project JSON. +- Do not make broad refactors during feature work. +- Do not introduce new runtime dependencies without documenting why they are needed. +- Do not couple React components to Web Audio scheduling internals. +- Do not change documented model semantics without updating the relevant docs. + +## Definition of Done + +- Requested behavior implemented. +- Relevant docs updated if needed. +- TypeScript passes. +- Lint passes. +- Tests pass. +- Production build passes. +- PR summary includes testing and known limitations. + +## Verification + +Run applicable checks before opening a PR: + +```bash +npm run typecheck --if-present +npm run lint --if-present +npm run test --if-present +npm run build --if-present +``` + +If the project scaffold does not exist yet, record that the npm scripts are not available and that `docs/features/01-project-scaffold.md` must add them. diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..77e4bd8 --- /dev/null +++ b/PLANS.md @@ -0,0 +1,78 @@ +# Plans + +## North Star + +Create a browser-first, clip-oriented mini DAW that is useful for making electronic music. The app should favor correct timing, serializable project data, and focused workflows over broad full-DAW scope. + +## Current Milestone: Develop to Main Stabilization + +The current branch is being prepared for a `develop` to `main` PR. The goal is to make the repository easy to understand, verify, and review at its current prototype stage. + +Focus: + +- Keep README, plans, and feature docs aligned with the implemented state. +- Keep the GitHub Issue queue and `Active Issue Order` clean. +- Verify the standard checks pass before opening release-prep PRs. +- Avoid adding new product scope during stabilization unless the user explicitly asks for it. + +## Active Issue Order + +No active implementation issues are queued right now. + +When new work is needed, create or update a feature spec under `docs/features/`, create a linked GitHub Issue, and add that issue here in priority order. + +## Completed Milestones + +1. Project scaffold. +2. AudioContext and sample playback. +3. Lookahead scheduler. +4. Drum step sequencer. +5. Basic piano roll. +6. Transport pause/resume and editor playhead. +7. Pitched instrument selection and Iowa Piano one-shot playback. +8. Tempo control and live BPM updates. +9. Arrangement view UI shell. +10. Sampler advanced sustain. +11. Sidebar clip and instrument management. +12. Drum step subdivisions. +13. Arrangement mixer panel UI shell. +14. Hybrid clip loop playback. +15. WAV file import as audio clip. +16. Arrangement clip placement and playback. +17. Mixer audio routing and track controls. +18. IndexedDB project persistence. +19. Variable hybrid clip length. +20. Adjustable arrangement length. +21. Arrangement WAV export. +22. Multi-project management. + +## Planned Milestones + +1. Project JSON export/import. +2. Basic effects. +3. Custom project-management dialogs to replace browser prompt/confirm flows. +4. Browser-driven smoke tests with Playwright or an equivalent e2e tool. +5. Broader manual/audio QA pass before treating the prototype as a stable release. + +## Backlog + +- Keyboard shortcuts for transport and editing. +- Basic undo and redo. +- Velocity editing for drum and note events. +- Clip duplication. +- Starter project template. +- Metronome. +- Quantize utilities. +- Swing or groove timing after strict timing is reliable. +- MIDI file import or export. +- Improved sampler sustain authoring and tuning UI. +- More complete effect slots and effect parameter persistence. + +## Frozen / Not Now + +- Realtime audio recording. +- VST/plugin support. +- Cloud sync. +- Multiplayer collaboration. +- Advanced audio mastering. +- Full DAW replacement scope. diff --git a/README.md b/README.md index e0f78f2..1fb9461 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ # mini-web-daw-Jaewan-Park -A Web Audio API-based mini DAW for sequencing synthesized and sampled instruments in the browser + +A browser-first mini DAW prototype for creating electronic music with clips, step sequencing, piano-roll notes, arrangement playback, and local project persistence. + +## Product Goal + +Build a practical clip-based music creation tool that can grow beyond a demo while staying focused, serializable, and timing-conscious. React renders the interface, but exact audio scheduling belongs to the Web Audio engine. + +## Stack + +- Vite +- React +- TypeScript +- CSS Modules +- Web Audio API +- IndexedDB for browser-local project and imported sample persistence +- ESLint, Vitest, and Vite build checks + +## Current Status + +The app is an early browser-based DAW prototype, not a full DAW replacement. Current develop-branch functionality includes: + +- Main DAW shell with transport, sidebar, clip editor, and arrangement mode. +- Drum step sequencer with lane sample selection, lane ordering, and step subdivisions. +- Piano roll note editing with Default Synth and Iowa Piano playback paths. +- Pattern and song transport playback with pause/resume, playhead, BPM control, and loop ranges. +- Clip sidebar workflows for creating, renaming, deleting, importing WAV files, and adding/removing instruments. +- Arrangement view for placing clips, moving/deleting clip instances, looped arrangement playback, adjustable arrangement length, mixer controls, and WAV export. +- Browser-local IndexedDB persistence, imported audio blob persistence, and multiple local projects. + +Known limits: + +- Project storage is local to the current browser profile. +- There is no cloud sync, account system, realtime recording, plugin support, or advanced mastering workflow. +- Some UI flows still use simple browser dialogs while the core behavior is being stabilized. + +## Getting Started + +```bash +npm install +npm run dev +``` + +Useful checks: + +```bash +npm run typecheck +npm run lint +npm run test +npm run build +``` + +## Documentation + +- [Product definition](docs/product.md) +- [Architecture](docs/architecture.md) +- [Data model](docs/data-model.md) +- [Audio engine](docs/audio-engine.md) +- [UI and design](docs/ui-design.md) +- [Testing strategy](docs/testing.md) +- [Code conventions](docs/code-conventions.md) +- [Project plans](PLANS.md) +- [Agent instructions](AGENTS.md) +- [Feature specs](docs/features/) + +Course-facing planning and workflow notes are maintained in the [GitHub Wiki](https://github.com/boostcampwm-snu-2026-1/mini-web-daw-Jaewan-Park/wiki). + +## Development Workflow + +Feature work is planned through GitHub Issues and feature specs under `docs/features/`. Implementation PRs target `develop` by default. Changes should stay small, keep project data serializable, store musical time in ticks, and keep React UI separate from exact audio scheduling. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1c2eff7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,120 @@ +# Architecture + +## High-level Architecture + +The app is a Vite + React + TypeScript browser application. React owns visual rendering and user interaction. The model layer owns serializable project data. The audio engine owns Web Audio runtime objects, scheduling, and playback. + +The main rule is separation of concerns: UI rendering, persistence, and audio scheduling must not be mixed in the same module. + +## Layer Responsibilities + +### UI layer + +- Render the transport, clip editor, drum sequencer, piano roll, and later arrangement views. +- Dispatch user actions to state/model logic. +- Display audio state such as stopped, playing, paused, and playhead position. +- Render song-level UI shells such as arrangement and mixer panels. +- Let users place, move, select, and delete arrangement clip instances. +- Let users adjust clip and arrangement lengths through model-backed controls. +- Dispatch mixer control edits such as volume, mute, solo, and master volume. +- Trigger save, restore, import, and export workflows through persistence/audio APIs. +- Let users create, select, rename, and delete browser-local projects through project-level UI. +- Use `requestAnimationFrame` for visual playheads where needed. +- Avoid owning exact audio timing. + +### State/model layer + +- Define serializable project data types. +- Store musical time in ticks. +- Provide pure transformations for creating, editing, duplicating, and deleting clips and events. +- Store clip length and arrangement length as serializable musical values. +- Store project identity and project metadata separately from runtime UI selection. +- Provide pure transformations for creating, moving, and deleting arrangement clip instances. +- Store serializable mixer settings such as track volume, mute, solo, and master volume when mixer routing exists. +- Avoid references to Web Audio runtime objects. + +### Audio engine + +- Own `AudioContext` lifecycle. +- Load and decode sample assets. +- Store runtime-only decoded sample data. +- Schedule audio against `AudioContext.currentTime`. +- Schedule arrangement playback from placed clip instances when `SONG` mode is active. +- Own mixer routing, track gain nodes, master gain, runtime meters, and effect nodes when those features exist. +- Provide an offline rendering path for arrangement WAV export when that feature exists. +- Expose a small typed API to the UI and feature code. +- Never depend on React components. + +### Persistence/import/export + +- Convert project data to and from JSON. +- Validate or migrate project versions when needed. +- Store sample references by stable IDs or metadata, not decoded buffers. +- Handle browser file import boundaries for user-provided audio files. +- Keep `File`, `Blob`, object URL, IndexedDB handles, and decoded audio buffers out of model data. +- Persist browser-local project documents and imported sample blobs through IndexedDB. +- Persist a browser-local project collection for create/select/rename/delete workflows. +- Store the active project ID separately from the project document. +- Export rendered arrangement audio as WAV without storing runtime audio objects in project JSON. + +### Utilities + +- Tick and time conversion. +- Musical grid math. +- ID generation if needed. +- Small pure helpers that are easy to test. + +## Suggested Source Layout + +```text +src/ + app/ + components/ + features/ + audio/ + model/ + persistence/ + utils/ + styles/ +``` + +## Dependency Direction Rules + +- `src/audio/` may depend on `src/model/` types and `src/utils/`, but not React components. +- `src/model/` should not depend on React, Web Audio, or browser storage APIs. +- `src/persistence/` may depend on model types and validation helpers. +- `src/features/` may compose UI, model operations, and audio engine APIs. +- Shared components should not import feature-specific state unless intentionally designed for that feature. + +## Runtime vs Serializable Data + +Serializable data includes projects, tracks, clips, clip instances, drum events, note events, sample metadata, tempo, time signature, clip length, arrangement length, arrangement loop ranges, and mixer settings. + +Runtime data includes `AudioContext`, `AudioBuffer`, audio nodes, scheduler timers, decoded sample caches, and currently playing source nodes. Runtime data must not be written into project JSON. + +Imported browser files are also runtime or persistence-layer data. Project JSON may reference imported audio by stable sample IDs and metadata such as file name, MIME type, and duration, but it must not embed `File`, `Blob`, object URL, or decoded PCM data. + +IndexedDB may store imported sample blobs or bytes outside the project JSON document. The model should reference those blobs by stable sample IDs so the audio engine can rebuild decoded runtime caches after restore. + +Each persisted project has a stable project ID. Imported sample blobs are scoped +to the owning project with project-aware records and composite blob keys. +Switching projects should stop live playback and preview before replacing app +state. Autosave must write to the intended project ID and must not accidentally +overwrite another project after a switch. + +Mixer settings such as volume, mute, solo, and master volume are serializable project or app-model data once real mixer routing exists. Mixer runtime data, including `GainNode`, `AnalyserNode`, effect nodes, meter buffers, and active routing graphs, belongs to the audio engine runtime and must not be stored in project JSON. + +Arrangement placement data is serializable. A placed clip should be represented by a `ClipInstance` with stable IDs, `startTick`, `lengthTicks`, and track membership. Drag state, pointer coordinates, DOM measurements, scheduler timers, decoded buffers, and active audio nodes are runtime-only and must not be persisted. + +## Future Desktop Packaging Considerations + +Keep the app browser-first, but avoid assumptions that block future Electron or Tauri packaging: + +- Keep file-system access behind persistence/import/export modules. +- Avoid direct dependency on Node APIs in UI and audio modules. +- Keep project files portable JSON. +- Keep sample references abstract enough to support browser object URLs now and local file paths later. + +## Audio Engine React Boundary + +The audio engine must not depend on React components. React may call a typed audio engine API, subscribe to status updates, and render visual feedback, but audio scheduling must remain independent from component render timing. diff --git a/docs/audio-engine.md b/docs/audio-engine.md new file mode 100644 index 0000000..a0b3b3d --- /dev/null +++ b/docs/audio-engine.md @@ -0,0 +1,308 @@ +# Audio Engine + +## Responsibilities + +- Manage `AudioContext` lifecycle. +- Load and decode sample assets. +- Play one-shot samples. +- Convert tick positions to audio time using tempo and PPQ. +- Schedule sequenced playback with a lookahead scheduler. +- Track transport state such as stopped, playing, paused, tempo, loop range, and playhead position. +- Expose a small typed API to the UI. + +## Non-responsibilities + +- Rendering React components. +- Owning project JSON persistence. +- Storing serializable project state. +- Handling visual layout. +- Implementing a full DAW mixer during early clip-editor milestones. + +## AudioContext Lifecycle + +Create the `AudioContext` in response to a user gesture when possible. Provide explicit start/resume and stop/suspend behavior. Browser autoplay policies mean audio setup must tolerate a suspended context until the user interacts. + +The app should have one primary audio engine instance for normal playback. + +## Sample Loading and Decoding + +Bundled sample files should be fetched as array buffers, decoded with `AudioContext.decodeAudioData`, and stored in a runtime cache keyed by `sampleId`. + +Decoded sample data is not serializable project data. Project JSON stores sample metadata and stable references only. + +In the browser implementation, decoded buffers should live in the audio engine runtime cache, not in React state or project data. UI code should trigger loading through the typed audio engine API and may display loaded sample IDs or context state returned by that API. + +Imported WAV files should follow the same runtime rule. The file picker may provide a `File` or `Blob`, but the audio engine should only keep decoded buffers in a runtime cache keyed by a stable imported sample ID. Project JSON stores metadata such as file name, MIME type, and duration, not the `File`, `Blob`, object URL, or `AudioBuffer`. + +IndexedDB persistence may store imported file bytes outside project JSON. After restore, the app passes those persistent blobs back through the audio engine's typed import API so decoded buffers can be rebuilt on demand. The audio engine should not depend on IndexedDB directly. If project metadata references an imported sample whose bytes are unavailable, the engine or feature orchestration should fail clearly instead of silently skipping playback. + +## Imported Audio Clip Playback + +Imported audio clips are source-media clips, not pitched instruments. + +The first imported WAV implementation may support a simple preview or clip playback path: + +- Decode the selected WAV after a user gesture. +- Store the decoded `AudioBuffer` in the runtime sample cache. +- Use `AudioBuffer.duration` to populate serializable duration metadata. +- Create a new `AudioBufferSourceNode` for each preview or scheduled playback. +- Schedule playback against `AudioContext.currentTime`. +- Stop active imported audio sources on transport stop or when switching away if required by the UI. + +Imported audio clip duration is source media duration in seconds. Future arrangement placement should convert arrangement positions and visible instance lengths to ticks, while source offsets remain sample-local seconds. Without a time-stretching feature, resizing an audio clip instance should trim/crop playback or extend silence rather than stretch the audio to a new musical duration. + +## Arrangement Playback + +`PAT` mode playback targets the selected clip editor. `SONG` mode playback should target placed `ClipInstance` objects on the arrangement timeline. + +Arrangement playback should: + +- Read serializable clip instances from project or app model state. +- Convert arrangement tick positions to `AudioContext.currentTime` scheduling times. +- Expand hybrid clip drum events to `clipInstance.startTick + drumEvent.startTick`. +- Expand hybrid clip note events to `clipInstance.startTick + noteEvent.startTick`. +- Schedule audio clips at `clipInstance.startTick` when their runtime sample data is available. +- Respect `clipInstance.lengthTicks` as the visible and playable duration boundary. +- Keep play, pause, resume, and stop behavior separate from React render timing. + +Arrangement playback derives its outer bounds from arrangement state, specifically `arrangementLengthBars` and the normalized loop range. Avoid reintroducing fixed 16-bar playback assumptions. + +The current first pass reuses the existing lookahead loop scheduler for `SONG` mode by setting a loop range that covers the visible arrangement. This keeps scheduling independent from React and enables pause/resume with the existing transport API, but it is not yet a true one-shot linear song transport. A later transport task should add non-looping arrangement playback that stops at the arrangement end. + +The arrangement view may set loop start and loop end at bar boundaries. `SONG` playback should pass those ticks as the scheduler `loopStartTick` and `loopEndTick`. Selecting clips or instruments in the sidebar while `SONG` mode is playing must not replace the active arrangement scheduler with selected-clip `PAT` events. + +Imported audio clips should play at original speed unless a later time-stretching feature explicitly changes that behavior. If an audio clip instance is shorter than the source buffer, playback should be cropped. If the instance is longer than the source buffer, playback may end naturally and leave silence. If runtime file data is missing after refresh, the engine should report a clear missing-source error rather than silently failing. + +Arrangement playback still uses the lookahead scheduler. UI drag state, arrangement DOM geometry, visual playheads, decoded buffers, and active source nodes remain runtime-only. + +## Offline WAV Export + +Arrangement WAV export should use an offline audio rendering path, not the live React UI or visual playhead. + +The first export target should be WAV because the browser can produce it from PCM data without a compressed-audio encoder dependency. A predictable first format is stereo 44.1 kHz 16-bit PCM WAV unless implementation constraints justify another choice in the PR. + +The current first export pass: + +- Renders from arrangement tick 0 through the configured arrangement length. +- Uses the same tick-to-seconds conversion rules as live playback. +- Includes arranged hybrid clip drums, `Default Synth` notes, Iowa Piano sampled notes, imported audio clips, track volume, track mute/solo, and master gain. +- Crops imported audio clip playback to the placed `ClipInstance.lengthTicks`; if the source ends first, the remaining placement renders silence. +- Blocks with a clear error when required imported sample data is missing. +- Avoids mutating live transport state, active source nodes, or decoded runtime caches during rendering. + +WAV encoding can be a small utility that converts rendered PCM into a Blob. MP3, FLAC, stem export, cloud export, and mastering processors are separate features. + +## Mixer Routing + +The first functional mixer should apply to `SONG` arrangement playback. It depends on scheduled sources knowing their arrangement `trackId`. + +The audio engine should own the routing graph: + +```text +scheduled source + -> track gain node + -> optional track meter analyser + -> master gain node + -> optional master meter analyser + -> AudioContext.destination +``` + +Track gain nodes should be keyed by stable `trackId`. When arrangement playback schedules a drum sample, synth note, sample-based note, or audio clip source, that source should connect to the appropriate track channel instead of directly to the destination. + +Mixer settings such as `volumeDb`, `muted`, `solo`, and master `volumeDb` are serializable model data. Web Audio nodes, analyser nodes, meter buffers, active source nodes, and the routing graph are runtime-only audio-engine data. + +Use a deterministic mute/solo rule: + +```text +anySolo = at least one track has solo = true +trackAudible = (!anySolo || track.solo) && !track.muted +``` + +If a track is both muted and soloed, muted wins and the track remains silent. + +Volume faders should map decibels to linear gain before updating `GainNode.gain`. Prefer a small utility for this conversion so it can be unit tested. + +The audio engine should expose a small typed mixer API to the UI or feature orchestration, for example: + +```ts +setTrackVolume(trackId, volumeDb) +setTrackMute(trackId, muted) +setTrackSolo(trackId, solo) +setMasterVolume(volumeDb) +getMixerLevels() +``` + +Implementation names may differ. React components should not hold or mutate Web Audio nodes directly. + +Level meters are display feedback only. The UI may poll meter snapshots with `requestAnimationFrame` or subscribe through an audio-engine callback. Meter timing must not drive audio scheduling. + +The current browser engine exposes meter snapshots as normalized runtime values from track and master `AnalyserNode` instances. These snapshots are display data only and are not serializable project state. + +The first mixer routing path applies to track-aware `SONG` arrangement events. Preview and `PAT` playback may continue using the existing direct playback path unless a later feature explicitly adds clip-editor mixer routing. + +## One-shot Sample Playback + +Use a new `AudioBufferSourceNode` for every one-shot playback. A source node cannot be restarted after it has played. + +Basic one-shot playback should: + +- Look up the decoded `AudioBuffer` by `sampleId`. +- Create a source node. +- Connect it to the appropriate destination or gain node. +- Schedule `source.start(when)`. + +## Basic Synth Note Playback + +The first piano roll playback path may use a simple Web Audio oscillator synth rather than stretching short sample files. This keeps held notes predictable because note duration comes from `durationTicks`. + +Basic synth note playback should: + +- Convert `midiNote` to oscillator frequency at scheduling time. +- Convert `durationTicks` to seconds using tempo and PPQ. +- Schedule oscillator start and stop against `AudioContext.currentTime`. +- Use a short gain envelope to avoid clicks. +- Treat oscillator nodes and gain nodes as runtime-only objects. + +This is not a full sampler instrument. Bundled pitched sample metadata can exist for future sampler work, but the initial held-note behavior should not depend on sample length. + +The oscillator instrument should be kept as `Default Synth` when sample-based pitched instruments are added. It is useful as a reliable fallback because it can sustain notes for arbitrary durations without sample loop metadata. + +## Sample-based Pitched Playback + +Iowa Piano should be a separate pitched instrument from `Default Synth`. It should use the bundled C4-C5 Iowa Piano WAV files when the piano roll note pitch has a matching sample. + +Sample-based pitched playback should: + +- Look up the selected pitched instrument. +- Map `midiNote` to a sample zone or bundled sample ID. +- Load and decode the sample into the runtime cache. +- Create a new `AudioBufferSourceNode` for each scheduled note. +- Schedule note start against `AudioContext.currentTime`. +- Convert `durationTicks` to seconds from tempo and PPQ. +- Use a gain envelope for attack and release. +- Stop or release active voices when transport stops. + +The current Iowa Piano implementation uses the shared sampler sustain path. Iowa Piano zones include explicit `sustain` metadata with forward-loop points and an envelope. If that metadata is valid for the decoded buffer and note duration, the source node uses `loop = true`, `loopStart`, and `loopEnd`. If the metadata is missing, unsupported, or invalid, playback falls back to one-shot sample behavior. + +Do not attempt automatic loop point detection in this pass. Loop points are explicit serializable metadata and should be tuned by editing the zone metadata or replacing sample material. + +Current Iowa Piano sample zones use explicit `sampleStartSeconds` offsets because the bundled one-second C4-C5 WAV files contain leading silence before the audible note attack. The engine should pass that value as the second argument to `AudioBufferSourceNode.start(when, offset)`. + +Current Iowa Piano loop metadata is a pragmatic first pass for the bundled 5-second samples. It uses a late tail loop region so short notes and most 1-bar notes play the natural sample decay without looping, while longer notes can loop the quieter tail instead of repeating the audible attack portion. Some samples may still have weak sustain quality depending on their source material. The engine must reject unsafe loop metadata rather than clamp invalid values into a loop. + +The audio engine should load sample zones needed by selected note events before handing them to the lookahead scheduler. If a sample-based instrument has no zone for a note, the engine may fall back to `Default Synth` behavior for that note rather than storing any runtime fallback state in project data. + +Each scheduled note event carries an `instrumentId`. The scheduler should play all note events in the selected clip, not only the currently visible piano roll lane, so multiple pitched instruments can sound together. + +When `decodeAudioData` cannot decode a bundled PCM WAV file, the browser engine may fall back to a local PCM WAV decoder and create an `AudioBuffer` manually. This fallback is runtime-only and does not change project data. + +## Sampler Sustain + +Sampler sustain should be a reusable audio-engine capability for sample-based pitched instruments, not an `Iowa Piano` special case. + +Sampler sustain should: + +- Read serializable sample-zone metadata such as `sampleStartSeconds`, `sampleEndSeconds`, sustain loop mode, loop start/end points, optional crossfade length, and envelope settings. +- Treat sample-local offsets, loop points, and envelope lengths as seconds because they describe positions inside a decoded sample buffer. +- Keep musical event positions and durations in ticks and convert them to seconds only at scheduling time. +- Validate loop metadata before enabling looping. +- Fall back to one-shot sample playback when loop metadata is missing or invalid. +- For one-shot sample playback, complete the release fade before the earlier of note end and the usable sample region end so the buffer does not end abruptly at non-zero gain. +- Apply release behavior at note end, transport pause, and transport stop so sustained voices do not remain stuck. + +The first implementation should prefer explicit metadata over automatic analysis. Automatic loop point detection, visual loop point editing, velocity layers, round-robin selection, and full sampler preset management belong in later tasks. + +If a zone uses a simple forward loop, the engine may use `AudioBufferSourceNode.loop`, `loopStart`, and `loopEnd`. If a future task adds crossfaded looping, it may need multiple scheduled source nodes and gain ramps because Web Audio buffer source looping does not provide native loop crossfade behavior. + +## Lookahead Scheduler Concept + +Do not rely on UI timers for exact playback. Sequenced playback should use a timer that wakes frequently, looks ahead by a short scheduling window, and schedules Web Audio events against `AudioContext.currentTime`. + +Example terms: + +- `lookaheadMs`: how often the scheduler wakes. +- `scheduleAheadTime`: how far into the future audio events are scheduled. +- `nextTick`: next musical tick to inspect. +- `loopStartTick` and `loopEndTick`: musical loop boundaries. + +The browser audio engine exposes sample loop playback through a typed API that accepts tick-based sample events, tempo, loop bounds, lookahead cadence, and schedule-ahead time. The scheduler itself is independent from React and can be unit tested without DOM rendering. + +The transport API should allow playback to start from a tick offset when resuming from pause. The offset is runtime state only and should be passed as a `startTick` option, not persisted into project data. + +## Tick-to-audio-time Conversion + +Ticks convert to seconds using tempo and PPQ: + +```ts +secondsPerBeat = 60 / tempoBpm; +secondsPerTick = secondsPerBeat / ppq; +seconds = ticks * secondsPerTick; +``` + +The documented default is PPQ 480. In 4/4, one bar is 1920 ticks and one 16-step grid step is 120 ticks. + +Drum step subdivisions still schedule ordinary tick-based `DrumEvent` objects. For a 16-step primary grid, a subdivision of `2` produces 60-tick substeps and a subdivision of `3` produces 40-tick substeps. Once a `DrumEvent.startTick` is produced, the scheduler should not need UI subdivision state to play it accurately. + +## Transport State + +Transport state should include: + +- Playback state: `stopped`, `playing`, or `paused`. +- Tempo in BPM. +- Current tick. +- Audio start time. +- Tick start offset. +- Loop enabled state. +- Loop start and end ticks. + +Transport state may be mirrored into React for display, but React render timing must not drive exact audio playback. + +Pause and stop have different meanings: + +- Pause captures the current runtime playhead tick and stops future scheduling. Resume should continue from that tick. +- Stop clears scheduling and resets the runtime playhead tick to the loop start, which is tick 0 for the M1 1-bar clip. +- The paused playhead position is runtime state only. It should not be written to project JSON. + +The audio engine should expose pause separately from stop. A pause operation preserves the scheduler object and its current tick snapshot; a stop operation clears the active loop and returns the transport to the loop start. + +## Tempo Control + +Tempo is musical project state, not UI-only state. The transport BPM slider should update the current `tempoBpm` value used by audio scheduling. + +Tempo changes should follow these rules: + +- When stopped, the next playback start uses the current BPM. +- When paused, resume uses the current BPM from the preserved paused tick. +- When playing, BPM changes should affect future lookahead scheduler windows. +- Existing musical event positions stay in ticks. Tempo changes alter tick-to-seconds conversion, not event tick positions. +- React may own the visible slider value, but exact event scheduling must continue to use audio-engine transport state and `AudioContext.currentTime`. + +Prefer an audio-engine API that can update active scheduler tempo while preserving the current runtime tick. If the first implementation restarts the selected clip loop from the current tick to apply tempo changes, document the limitation and keep the schedule-ahead window short enough for interactive use. + +## Looping Behavior + +For M1, pattern loop playback targets the selected hybrid clip. The default loop range is 0 to 1920 ticks for a 1-bar clip, but 2-bar and 4-bar hybrid clips must pass `loopEndTick` as 3840 or 7680 ticks respectively. + +Do not hard-code one-bar loop boundaries in the UI or feature orchestration once a clip has a `lengthTicks` value. The audio engine scheduler already accepts tick-based loop boundaries; the caller should provide the active clip length. + +Events at the loop start should play when the loop begins. Events at the loop end should belong to the next loop iteration only if explicitly represented there; avoid double-triggering boundary events. + +Loop stop clears the scheduler timer and prevents future windows from being scheduled. Events already submitted to Web Audio inside the current schedule-ahead window may still play briefly; keep `scheduleAheadTime` short enough that this limitation remains acceptable for interactive editing. + +When the user edits a drum pattern during playback, the UI may update the scheduler's event list without restarting transport. Newly scheduled windows should use the latest serializable drum events. Events already submitted to Web Audio inside the current schedule-ahead window may still reflect the previous pattern because Web Audio scheduled source nodes cannot be unscheduled after `start(when)`. + +When the user edits piano roll notes during playback, the UI may update the same selected-clip loop event list with the latest serializable note events. Newly scheduled windows should use the latest note positions and durations. Already scheduled synth voices inside the current schedule-ahead window may briefly reflect the previous note data. + +## UI Playhead Separation + +UI cursor and playhead animation may use `requestAnimationFrame` and read transport position from the audio engine. Visual playhead timing can be approximate. Exact sound timing must come from scheduled Web Audio events. + +The UI may render a vertical playhead over the piano roll or drum sequencer by converting the current runtime tick into editor geometry. The playhead should wrap at the active loop boundary. It is display feedback only; moving or rendering the playhead must not be required for audio events to play on time. + +## Future Extension Points + +- Sampler instrument. +- Synth instruments. +- More advanced mixer routing such as pan, sends, buses, automation, and recording arm. +- Effects hosted as audio-engine-owned Web Audio nodes. +- Offline/export rendering later. diff --git a/docs/code-conventions.md b/docs/code-conventions.md new file mode 100644 index 0000000..a48d2e0 --- /dev/null +++ b/docs/code-conventions.md @@ -0,0 +1,90 @@ +# Code Conventions + +## Purpose + +Use these conventions to keep agent-authored code consistent across features. Prefer existing local patterns when they are more specific than this document. + +## Files and Directories + +- React component files use `PascalCase.tsx`: `TransportBar.tsx`, `PianoRoll.tsx`. +- Component CSS Modules use the same base name: `TransportBar.module.css`. +- Feature folders use `kebab-case`: `drum-step-sequencer`, `piano-roll`. +- General TypeScript utility files use `kebab-case.ts`: `tick-time.ts`, `sample-loader.ts`. +- Barrel files may use `index.ts` only when they simplify imports without hiding ownership. +- Unit test files use `*.test.ts` or `*.test.tsx` under `tests/unit/`, grouped by the production area they cover. + +## TypeScript Naming + +- Types, interfaces, classes, enums, and React components use `PascalCase`. +- Functions, variables, object properties, and module-level helpers use `camelCase`. +- True constants use `UPPER_SNAKE_CASE`: `DEFAULT_PPQ`, `BAR_TICKS`, `STEP_TICKS`. +- Generic type parameters should be short and meaningful: `T`, `TEvent`, `TClip`. +- Avoid abbreviations unless they are common in the domain, such as `MIDI`, `PPQ`, `BPM`, or `ID`. + +## React Components + +- Component names should match product nouns: `TransportBar`, `ClipEditor`, `DrumStepSequencer`. +- Event handlers use `handleX`: `handlePlayClick`, `handleStepToggle`. +- Custom hooks use `useX`: `useTransportState`. +- Components should not own exact audio timing or scheduling logic. +- Keep feature-specific UI under `src/features/` unless it is clearly reusable. +- Keep shared presentational components under `src/components/`. + +## Domain Naming + +- Tick values should use `Tick` or a `Ticks` suffix when they represent a duration or length: + - `startTick` + - `durationTicks` + - `lengthTicks` +- Seconds should be explicit: + - `durationSeconds` + - `lookaheadSeconds` + - `scheduleAheadSeconds` +- Web Audio clock values should make audio-time semantics clear: + - `audioStartTime` + - `scheduledAudioTime` +- IDs use `Id` suffix in property names: + - `clipId` + - `trackId` + - `sampleId` +- Booleans should start with `is`, `has`, `can`, or `should`: + - `isPlaying` + - `hasSelection` + - `canResizeNote` + - `shouldLoop` + +## CSS Modules + +- CSS Module class names use `camelCase`: `root`, `transportBar`, `stepButton`, `activeStep`. +- Prefer product or component part names over visual-only names. +- Keep static appearance in CSS Modules. +- Runtime editor geometry may use inline styles when values are derived from ticks, pitch, or grid dimensions. +- Do not put component-specific styles in global CSS. + +## Design Tokens + +- Define primitive tokens first: raw palette, spacing, typography, radius, and timing values. +- Define semantic tokens from primitive tokens. +- Component CSS Modules should use semantic tokens, not primitive tokens. +- Primitive token example: `--color-neutral-900`, `--space-4`, `--radius-2`. +- Semantic token example: `--color-app-background`, `--space-control-gap`, `--radius-control`. +- Document exceptions when component code must reference a primitive token directly. + +## Tests + +- Test names should describe behavior, not implementation. +- Keep production source files under `src/`; place unit tests under `tests/unit/`. +- Use `tests/integration/` only when a task needs multi-module workflow coverage. +- Do not add an end-to-end test framework until a feature spec calls for browser flow testing. +- Prefer examples such as: + - `converts ticks to seconds at 120 bpm` + - `does not schedule duplicate events at the loop end` +- Unit-test pure utilities, tick/time conversion, data model transformations, and scheduler calculations. +- Add component or end-to-end tests later when UI behavior becomes stable enough to justify them. + +## Comments + +- Prefer clear names and small functions over explanatory comments. +- Add comments only where the reason is not obvious from the code. +- Avoid comments that restate the next line of code. +- Use comments to explain timing assumptions, loop-boundary decisions, browser audio constraints, or non-obvious model migrations. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..bff2ea7 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,597 @@ +# Data Model + +## Tick-based Time Model + +Store musical time in ticks, not seconds. The recommended default PPQ is 480 pulses per quarter note. + +For 4/4: + +- 1 beat = 480 ticks. +- 1 bar = 1920 ticks. +- 2 bars = 3840 ticks. +- 4 bars = 7680 ticks. +- 16-step grid step = 120 ticks. + +Seconds are derived at playback time from ticks and tempo. Do not store seconds as the primary event position. + +## Tempo + +Project tempo should be represented as serializable BPM data, using a field such as `tempoBpm` on the project or current app-level project state until full persistence exists. + +Changing tempo must not rewrite clip event positions. Drum events and note events keep their `startTick` and `durationTicks`; the audio engine converts those ticks to seconds using the current `tempoBpm` at scheduling time. + +The initial transport UI range is 60 to 180 BPM. Implementations should validate or clamp tempo values before passing them to scheduler or tick/time conversion utilities. + +## Core Entities + +- `Project`: top-level serializable project document. +- `ProjectSummary`: lightweight local project list item. +- `ProjectCollectionState`: browser-local project collection metadata such as the active project ID. +- `Track`: a lane that can contain clip instances. +- `Clip`: reusable musical content. It may be a hybrid MIDI/drum clip or, later, an imported audio clip. +- `ClipInstance`: placement of a clip on a track in arrangement time. +- `AudioClip`: reusable clip content that references imported audio metadata. +- `DrumEvent`: drum hit inside a clip. +- `NoteEvent`: pitched note inside a clip. +- `SampleMeta`: serializable metadata for a sample. +- `PitchedInstrumentMeta`: serializable metadata for a pitched instrument. +- `TrackMixerState`: serializable track mixer settings when real mixer routing exists. +- `MasterMixerState`: serializable master output settings when real mixer routing exists. + +## Project Collection + +The app supports multiple browser-local projects through IndexedDB project +documents plus separate collection metadata. + +`Project` remains the serializable document for one song or sketch. Each project should have a stable ID and contain the musical data already documented in this file: clips, arrangement tracks, clip instances, arrangement length, loop range, sample metadata, tempo, mixer settings, and related project fields. + +Project collection metadata should be stored separately from individual project documents: + +```ts +export interface ProjectSummary { + id: string; + name: string; + createdAt: number; + updatedAt: number; +} + +export interface ProjectCollectionState { + activeProjectId: string; + projects: ProjectSummary[]; +} +``` + +The current implementation keeps one active project in memory at a time. The +active project ID is serializable browser-local state so a refresh restores the +last selected project. + +Imported sample blobs are not project JSON. Imported blob records are scoped to +the owning project so two projects can safely have the same local `sampleId`. +The first implementation stores imported blobs in a project-scoped IndexedDB +store keyed by a composite ID such as `${projectId}::${sampleId}` and also keeps +the `projectId` on the record for deletion by project. + +Switching projects should not mutate the outgoing project document except for an intentional save or autosave flush. Runtime UI selection, decoded sample caches, active source nodes, transport state, and audio preview state should be reset or rebuilt for the newly active project. + +## Hybrid Clips + +Early clips may contain both drum events and note events. This keeps the M1 editor focused: one clip can hold a drum pattern and a piano roll phrase. + +Later, the model can evolve toward separate drum, MIDI, and audio clip types if arrangement and editing workflows need stronger separation. + +Hybrid clip length is represented as `lengthTicks`. Supported M1 lengths are 1, 2, and 4 bars: 1920, 3840, or 7680 ticks. Event positions and note durations remain tick-based and must stay inside the clip length. + +Editor grids derive from `lengthTicks`. Drum sequencing uses 16 primary steps per bar, so 1, 2, and 4 bar clips expose 16, 32, and 64 primary drum steps. Piano roll editing uses 32 columns per bar, so 1, 2, and 4 bar clips expose 32, 64, and 128 columns. + +Shortening a hybrid clip must not silently drop data. The UI should require confirmation before deleting drum events outside the new length or trimming note durations that extend beyond the new end tick. + +Imported WAV files should use a separate audio clip shape rather than forcing audio file state into the M1 hybrid clip fields. + +## Mixer State + +The first arrangement mixer panel should be a UI shell and may keep fader, mute, solo, meter, and effect-slot values as local or mock UI state. + +Do not silently introduce persisted mixer semantics in a UI-only feature. When mixer routing is implemented, track and master mixer state should become serializable project data or serializable app-model data while runtime audio objects remain outside project JSON. + +The first functional mixer implementation keeps track mixer settings as app-model state keyed by `trackId`, with a separate master mixer state. This remains serializable and can move into the future persisted `Project` shape without storing Web Audio nodes. + +Recommended first mixer state: + +```ts +export interface TrackMixerState { + trackId: string; + volumeDb: number; + muted: boolean; + solo: boolean; +} + +export interface MasterMixerState { + volumeDb: number; +} +``` + +Default values should be `volumeDb: 0`, `muted: false`, `solo: false`, and master `volumeDb: 0`. + +The first fader range is `-60 dB` to `+6 dB`. `-60 dB` is treated as silent for practical gain calculation. + +Use a simple, testable solo rule: + +```text +anySolo = at least one track has solo = true +trackAudible = (!anySolo || track.solo) && !track.muted +``` + +If a track is both muted and soloed, muted wins and the track remains silent. + +`GainNode`, `AnalyserNode`, effect nodes, meter buffers, and active routing graphs are runtime-only audio-engine data. Project JSON should store only settings and stable IDs. Level meter values are runtime display data and should not be persisted. + +Effect slots may remain UI placeholders until a basic effects feature introduces serializable effect state intentionally. + +## M1 Clip Collection and Sidebar Membership + +The M1 browser app should maintain an ordered collection of reusable hybrid clips. This may live in app-level project state before full export/import exists, but the data itself should be serializable and compatible with the future `Project.clips` field. + +Runtime UI selection, such as `selectedClipId` and the selected sidebar item, may remain app state. The clip list, clip names, drum lane settings, pitched instrument membership, drum events, and note events should be serializable. + +Every hybrid clip has a mandatory `Drums` child item in the sidebar. The `Drums` item represents the clip's `drumLanes` and `drumEvents`; it is not stored as a pitched instrument and should not be removable in the first sidebar management feature. + +New clips start with only the mandatory `Drums` child item. Pitched instruments such as `Default Synth` and `Iowa Piano` are added explicitly and then stored in `pitchedInstrumentIds`. + +Pitched instruments that are available inside a clip should be stored by serializable ID, for example: + +```ts +export interface HybridClip { + id: string; + kind: "hybrid"; + name: string; + lengthTicks: Tick; + drumStepSubdivision: 1 | 2 | 3; + drumLanes: DrumLaneDefinition[]; + drumEvents: DrumEvent[]; + pitchedInstrumentIds: string[]; + noteEvents: NoteEvent[]; +} +``` + +`pitchedInstrumentIds` controls which pitched instrument child items appear under the clip. `NoteEvent.instrumentId` still owns each note, so multiple pitched instruments can coexist inside one hybrid clip and play together. + +The first sidebar management implementation allows a clip to have zero pitched instruments. When this happens, the piano roll should display `-` as the instrument name and avoid creating pitched notes until an instrument is added. + +Deleting a pitched instrument from a clip must deliberately handle notes owned by that instrument. Prefer requiring confirmation before deleting those notes. If confirmation UI is not available, disable deletion while owned notes exist and make the reason clear. + +## Imported Audio Clips + +Imported WAV files should create audio clips that reference serializable sample metadata. + +The model should keep these concepts separate: + +- Source media metadata: file name, MIME type, display name, duration, and stable sample ID. +- Clip identity: the reusable audio clip shown in the sidebar. +- Runtime media data: `File`, `Blob`, object URL, decoded `AudioBuffer`, and active source nodes. +- Future arrangement placement: where a clip instance appears in song time and how long that instance lasts. + +Imported file bytes and decoded sample data are not project JSON. IndexedDB persistence may store imported blobs outside the project document and connect them back through stable sample IDs. Until that persistence feature exists, imported audio clips may be session-only and should be documented in the UI. + +The IndexedDB persistence implementation stores each project document separately +from imported sample blobs. The project document may include clips, arrangement +tracks, clip instances, loop range, sample metadata, tempo, track mixer state, +and master mixer state. Imported sample blobs are stored in a project-scoped blob +store and are not embedded inside the project document. + +Illustrative shape: + +```ts +export interface AudioClip { + id: string; + kind: "audio"; + name: string; + sampleId: string; + sourceFileName: string; + durationSeconds: number; +} +``` + +`durationSeconds` describes the source media. It is acceptable here because it is not a musical event position. Arrangement positions and clip instance lengths should still use ticks. + +Future arrangement resizing should be non-destructive. The arrangement should store resize/trim decisions on `ClipInstance`, for example `lengthTicks` and optional `sourceOffsetSeconds`, instead of modifying the source audio clip or embedded file. Without a dedicated time-stretching feature, resizing an imported audio clip instance should mean trimming/cropping playback or showing silence after the source ends; it should not imply tempo-matched stretching. + +## Arrangement Clip Placement + +The arrangement view places reusable clips on tracks using `ClipInstance` objects. + +`Clip` owns reusable source content: + +- Hybrid clip drum and note events. +- Audio clip source metadata. +- Clip name and source identity. + +`ClipInstance` owns song placement: + +- Which source clip is placed. +- Which track contains it. +- Where it starts in arrangement ticks. +- How long the placed instance lasts in arrangement ticks. +- Optional source offset for audio clips. + +`ArrangementState` owns arrangement-level song settings such as: + +- Total arrangement length in bars. +- Current loop range. + +Arrangement length is serializable state represented as `lengthBars`. The first implementation defaults to 16 bars, keeps a minimum of 1 bar, and uses 128 bars as the practical maximum. The arrangement ruler, grid, scroll width, loop bounds, playback bounds, persistence, and export duration should derive from this state. + +`ArrangementLoopRange` owns the current song playback loop boundaries: + +- Loop start tick. +- Loop end tick. +- Boundaries should snap to 4/4 bar boundaries in the first implementation. + +Illustrative shape: + +```ts +export interface ClipInstance { + id: string; + clipId: string; + trackId: string; + startTick: Tick; + lengthTicks: Tick; + sourceOffsetSeconds?: number; +} + +export interface ArrangementLoopRange { + startTick: Tick; + endTick: Tick; +} + +export interface ArrangementState { + lengthBars: number; + loopRange: ArrangementLoopRange; +} +``` + +The first arrangement placement feature should create, move, select, and delete `ClipInstance` objects without mutating the source `Clip`. Deleting a placed clip from the arrangement removes only that instance. It does not delete the sidebar clip. + +Deleting a source clip from the sidebar removes that clip and all of its arrangement `ClipInstance` placements. The UI should require confirmation when the source clip has musical events, is an imported audio clip, or has one or more arrangement placements. + +The first implementation keeps arrangement length, arrangement tracks, clip instances, and loop range in app-level state. The data is still serializable and should map directly into future `Project.tracks`, `Project.clipInstances`, and arrangement transport fields when export/import is implemented. + +When reducing `lengthBars`, clip instances that would extend beyond the new end should not be deleted silently. The first implementation confirms the destructive action and removes out-of-range arrangement placements without trimming source clips. + +Default instance lengths: + +- Hybrid clip: use the source clip's `lengthTicks`, initially 1920 ticks for a 1-bar clip. +- Audio clip: derive an initial `lengthTicks` from `durationSeconds` and current `tempoBpm` when placed, or use an equivalent helper that keeps arrangement placement tick-based. + +Without time stretching, imported audio playback runs at original speed. If an audio clip instance is shorter than the source, playback is cropped. If it is longer than the source, playback may end naturally and leave silence. Changing project tempo can change the musical grid without changing the underlying audio source speed until a later time-stretching feature exists. + +Snap and movement should update tick values, not pixel positions. UI geometry is derived from `startTick`, `lengthTicks`, track order, and timeline constants. + +## Bundled Drum Sample Naming and Display + +Bundled drum sample files live under `public/samples/drums/`. + +Use descriptive `.wav` file names with words separated by underscores, for example: + +```text +Fred_Kick_1.wav +Fred_Closed_Hi-Hat.wav +``` + +The app derives sample metadata from the file name: + +- Sample IDs are the file stem lowercased with underscores replaced by hyphens. +- Display names remove `.wav`, replace underscores with spaces, and uppercase the result. + +Examples: + +- `Fred_Kick_1.wav` -> sample ID `fred-kick-1`, display name `FRED KICK 1`. +- `Fred_Closed_Hi-Hat.wav` -> sample ID `fred-closed-hi-hat`, display name `FRED CLOSED HI-HAT`. + +## Initial Drum Clip Implementation + +The drum step sequencer stores lane settings and drum hits in the selected hybrid clip. A 16-step-per-bar grid maps step indices to ticks with `stepIndex * 120`; longer clips extend this same mapping across the selected clip length. + +The clip stores `drumLanes` as an ordered array. That array controls both visual lane order and the current sample assigned to each lane. Reordering lanes or changing a lane's sample must update serializable clip state, not runtime-only audio state. + +Initial drum lanes map to bundled sample IDs: + +- `kick` -> `fred-kick-1` +- `snare` -> `fred-snare-1` +- `closedHat` -> `fred-closed-hi-hat` +- `openHat` -> `fred-open-hi-hat` + +Drum event IDs are deterministic within a clip using the clip ID, lane ID, and start tick. Runtime playback converts these serializable events into audio engine sample loop events; the project model itself does not store `AudioBuffer` or other Web Audio objects. + +When a lane sample changes, existing `DrumEvent` objects for that lane should be updated to the new `sampleId` so playback and project export reflect the visible lane setting. + +## Drum Step Subdivisions + +The sequencer may keep the visible `1` through `16` primary step labels while allowing each primary step to split into smaller substeps. + +For the first subdivision feature, support clip-level subdivision values `1`, `2`, and `3`: + +- `1`: current 16-step behavior. +- `2`: two substeps per primary step. +- `3`: three substeps per primary step. + +The primary step length remains 120 ticks. Substep length is derived from the selected subdivision: + +```text +substepTicks = 120 / drumStepSubdivision +``` + +At PPQ 480: + +- subdivision `1` -> 120 ticks. +- subdivision `2` -> 60 ticks. +- subdivision `3` -> 40 ticks. + +`DrumEvent.startTick` remains the source of truth. UI step indexes are derived from ticks and should not replace tick storage. + +A clip may store the selected subdivision as serializable state: + +```ts +drumStepSubdivision: 1 | 2 | 3; +``` + +Changing subdivision should not rewrite existing drum event tick positions. Events that align with the selected subdivision can render as active substeps. Events that do not align with the selected subdivision should be preserved rather than silently deleted. + +## Bundled Piano Sample Naming and Display + +Bundled pitched instrument samples may live under `public/samples/pitched_instruments/`. + +The initial piano roll uses the Iowa Piano sample set under: + +```text +public/samples/pitched_instruments/Iowa_Piano/ +``` + +The initial bundled files cover C4 through C5: + +```text +C4.wav +Db4.wav +D4.wav +Eb4.wav +E4.wav +F4.wav +Gb4.wav +G4.wav +Ab4.wav +A4.wav +Bb4.wav +B4.wav +C5.wav +``` + +Sample IDs use the stable prefix `iowa-piano-` plus the lowercased pitch name, for example: + +- `C4.wav` -> `iowa-piano-c4` +- `Db4.wav` -> `iowa-piano-db4` + +The piano roll uses these files to define the initial C4-C5 pitch range and keep bundled sample metadata available. `Default Synth` remains the oscillator-based fallback instrument, while `Iowa Piano` uses the bundled WAV files for sample-based note playback. + +## Pitched Instruments + +Pitched instrument selection should distinguish the sound source used for `NoteEvent` playback from the notes themselves. + +Initial pitched instrument IDs: + +- `default-synth`: oscillator-based playback. It can hold notes for arbitrary durations. +- `iowa-piano`: sample-based playback using bundled Iowa Piano WAV files. + +Instrument selection may start as selected-clip or runtime UI state during early M1 work. If it becomes part of saved project behavior, store only serializable IDs and metadata, not runtime audio objects. + +Each `NoteEvent` stores the serializable `instrumentId` that owns that note. This allows multiple pitched instruments, such as `Default Synth` and `Iowa Piano`, to have notes at the same tick and pitch inside one hybrid clip and play simultaneously. + +Iowa Piano can use sample zones to map MIDI notes to bundled samples and optional sustain loop metadata: + +```ts +export interface PitchedInstrumentMeta { + id: "default-synth" | "iowa-piano" | string; + name: string; + kind: "synth" | "sample"; + zones?: SampleZone[]; +} + +export interface SampleZone { + sampleId: string; + midiNote: number; + rootMidiNote: number; + sampleStartSeconds?: number; + sampleEndSeconds?: number; + sustain?: SamplerSustainMeta; + envelope?: SamplerEnvelopeMeta; +} + +export interface SamplerSustainMeta { + mode: "none" | "forward-loop" | "crossfade-loop"; + loopStartSeconds?: number; + loopEndSeconds?: number; + crossfadeSeconds?: number; +} + +export interface SamplerEnvelopeMeta { + attackSeconds?: number; + releaseSeconds?: number; +} +``` + +The `sustain` fields are serializable metadata. They describe how the runtime audio engine may configure sample sustain playback. A simple `forward-loop` mode may map to `AudioBufferSourceNode.loopStart` and `loopEnd`; a future `crossfade-loop` mode may require additional scheduled source nodes and gain ramps. + +The `sampleStartSeconds` field skips leading silence before note attack. `sampleEndSeconds`, sustain loop points, crossfade length, and envelope values are sample-local seconds because they describe positions or durations inside a sample, not musical event time. + +Current Iowa Piano sample zones include explicit `forward-loop` sustain metadata and basic envelope metadata for the bundled 5-second samples. The loop points use a late tail region so short notes can use the natural sample decay and longer notes avoid repeating the audible note attack. These fields are still serializable sample-zone data only. The runtime audio engine validates them against decoded buffer duration and note duration before enabling `AudioBufferSourceNode.loop`; invalid or unsupported metadata falls back to one-shot sample playback. + +## Initial Piano Roll Implementation + +The initial piano roll stores note events directly in the selected hybrid clip's `noteEvents` array. Notes are serializable data: + +- `midiNote`: MIDI note number, initially C4 through C5. +- `instrumentId`: pitched instrument that owns and plays the note. +- `startTick`: note start position inside the clip. +- `durationTicks`: note length. +- `velocity`: normalized gain from 0 to 1. + +The initial visual piano roll grid has 32 columns across the 1-bar clip. At PPQ 480, one bar is 1920 ticks, so one piano roll grid column is 60 ticks. Drum sequencing still uses the 16-step grid where each step is 120 ticks. + +The UI may allow left-click or drag creation, dragging existing notes to move pitch/time, and right-click deletion. These interactions must update `noteEvents` in serializable clip state. Runtime audio objects used for synth playback or sample decoding must stay outside project JSON. + +## Illustrative Types + +These snippets show model intent. Implementation may refine names and fields, but changes to model semantics must update this document. + +```ts +export type Tick = number; + +export type Clip = HybridClip | AudioClip; + +export interface Project { + id: string; + version: number; + name: string; + tempoBpm: number; + timeSignature: { + numerator: 4; + denominator: 4; + }; + ppq: 480; + arrangement: ArrangementState; + tracks: Track[]; + clips: Clip[]; + samples: SampleMeta[]; + instruments?: PitchedInstrumentMeta[]; + masterMixer?: MasterMixerState; +} + +export interface Track { + id: string; + name: string; + kind: "hybrid" | "drum" | "instrument" | "audio"; + clipInstances: ClipInstance[]; + mixer?: TrackMixerState; +} + +export interface HybridClip { + id: string; + kind: "hybrid"; + name: string; + lengthTicks: Tick; + drumStepSubdivision: 1 | 2 | 3; + drumLanes: DrumLaneDefinition[]; + drumEvents: DrumEvent[]; + pitchedInstrumentIds: string[]; + noteEvents: NoteEvent[]; +} + +export interface AudioClip { + id: string; + kind: "audio"; + name: string; + sampleId: string; + sourceFileName: string; + durationSeconds: number; +} + +export interface DrumLaneDefinition { + id: "kick" | "snare" | "closedHat" | "openHat"; + label: string; + sampleId: string; +} + +export interface ClipInstance { + id: string; + clipId: string; + trackId: string; + startTick: Tick; + lengthTicks: Tick; + sourceOffsetSeconds?: number; +} + +export interface ArrangementState { + lengthBars: number; + loopRange: ArrangementLoopRange; +} + +export interface ArrangementLoopRange { + startTick: Tick; + endTick: Tick; +} + +export interface DrumEvent { + id: string; + laneId: "kick" | "snare" | "closedHat" | "openHat" | string; + startTick: Tick; + velocity: number; + sampleId: string; +} + +export interface NoteEvent { + id: string; + instrumentId: "default-synth" | "iowa-piano" | string; + midiNote: number; + startTick: Tick; + durationTicks: Tick; + velocity: number; +} + +export interface SampleMeta { + id: string; + name: string; + durationSeconds?: number; + source: { + kind: "bundled" | "imported"; + path?: string; + fileName?: string; + mimeType?: string; + }; +} + +export interface TrackMixerState { + trackId: string; + volumeDb: number; + muted: boolean; + solo: boolean; +} + +export interface MasterMixerState { + volumeDb: number; +} + +export interface PitchedInstrumentMeta { + id: "default-synth" | "iowa-piano" | string; + name: string; + kind: "synth" | "sample"; + zones?: SampleZone[]; +} + +export interface SampleZone { + sampleId: string; + midiNote: number; + rootMidiNote: number; + sampleStartSeconds?: number; + sampleEndSeconds?: number; + sustain?: SamplerSustainMeta; + envelope?: SamplerEnvelopeMeta; +} + +export interface SamplerSustainMeta { + mode: "none" | "forward-loop" | "crossfade-loop"; + loopStartSeconds?: number; + loopEndSeconds?: number; + crossfadeSeconds?: number; +} + +export interface SamplerEnvelopeMeta { + attackSeconds?: number; + releaseSeconds?: number; +} +``` + +## Runtime-only Audio Data + +`AudioBuffer` and decoded sample data are runtime-only. Project files should reference samples by stable IDs, paths, or metadata. They must not embed `AudioBuffer`, `AudioNode`, object URLs, or decoded sample contents. + +Use a runtime sample cache keyed by `sampleId` when playback needs decoded audio. + +After restoring an imported audio clip from IndexedDB, the app should decode the stored blob back into the audio engine runtime cache on demand, such as when previewing the clip or starting arrangement playback. diff --git a/docs/features/01-project-scaffold.md b/docs/features/01-project-scaffold.md new file mode 100644 index 0000000..f7a8682 --- /dev/null +++ b/docs/features/01-project-scaffold.md @@ -0,0 +1,61 @@ +# Feature: 01 Project Scaffold + +## Status +Done + +## Goal + +Create the Vite + React + TypeScript app structure for the browser-based mini DAW. + +## Context + +The repository currently contains planning and workflow documentation. This task should establish the app scaffold, package scripts, CSS Modules baseline, and initial source folders without implementing DAW features. + +## Scope + +Included: +- Create a Vite + React + TypeScript scaffold. +- Use npm as the package manager unless the repository clearly changes package managers. +- Add scripts for `dev`, `typecheck`, `lint`, `test`, and `build`. +- Add initial `src/` folders that match `docs/architecture.md`. +- Add baseline CSS Modules support and global styles location. +- Add initial design token files using primitive tokens and semantic tokens. +- Add a minimal app shell that does not claim DAW functionality. + +Excluded: +- Audio engine implementation. +- Drum sequencer implementation. +- Piano roll implementation. +- Sample files. +- Arrangement view. +- Persistence features. + +## Constraints +- Use Vite, React, TypeScript, CSS Modules, and Web Audio API as the planned stack. +- Component CSS Modules should use semantic design tokens, not primitive token values directly. +- Do not introduce unnecessary production dependencies. +- Preserve existing docs and workflow files. +- Keep changes small and reviewable. + +## Done when +- The app scaffold builds with Vite. +- Package scripts exist for the documented commands. +- Initial source folders exist. +- CSS Modules are usable by components. +- Initial design tokens include primitive values and semantic aliases. +- The app shell clearly indicates early scaffold status without overclaiming features. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Start `npm run dev` and confirm the app shell renders in the browser. + +## PR notes +- Summarize scaffold choices. +- Mention any package scripts or checks that are intentionally minimal. +- Note that DAW features are not implemented in this task. diff --git a/docs/features/02-audio-context-and-sample-playback.md b/docs/features/02-audio-context-and-sample-playback.md new file mode 100644 index 0000000..f58448c --- /dev/null +++ b/docs/features/02-audio-context-and-sample-playback.md @@ -0,0 +1,60 @@ +# Feature: 02 AudioContext and Sample Playback + +## Status +Done + +## Goal + +Add basic Web Audio setup and one-shot playback for bundled drum samples. + +## Context + +The mini DAW needs a small audio engine before sequenced playback. This task should establish `AudioContext` lifecycle handling, sample loading and decoding, and a typed playback API. + +## Scope + +Included: +- Create an audio engine module under `src/audio/`. +- Set up `AudioContext` creation/resume behavior. +- Load bundled drum samples when the scaffold has an asset strategy. +- Decode samples into runtime `AudioBuffer` objects. +- Store decoded buffers in a runtime cache keyed by sample ID. +- Add one-shot sample playback. +- Expose a small typed API to the UI. +- Add minimal manual UI only if useful for verifying playback. + +Excluded: +- Sequenced playback. +- Lookahead scheduler. +- Drum sequencer UI. +- Piano roll UI. +- User sample import. +- Arrangement playback. +- Effects or mixer. + +## Constraints +- Do not store `AudioBuffer` objects in project JSON. +- Create a new `AudioBufferSourceNode` for each one-shot playback. +- Keep audio engine logic independent from React components. +- Avoid new dependencies unless clearly justified. + +## Done when +- A user gesture can initialize or resume audio. +- Bundled sample metadata can resolve to decoded runtime buffers. +- A sample can be triggered repeatedly as a one-shot. +- The UI calls a typed audio API rather than Web Audio internals directly. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- In the browser, trigger each available sample more than once and confirm playback works. +- Stop/reload the page and confirm audio setup handles browser autoplay policy. + +## PR notes +- Document where runtime buffers are cached. +- Mention any browser-specific behavior observed during manual testing. diff --git a/docs/features/03-lookahead-scheduler.md b/docs/features/03-lookahead-scheduler.md new file mode 100644 index 0000000..b883d8d --- /dev/null +++ b/docs/features/03-lookahead-scheduler.md @@ -0,0 +1,58 @@ +# Feature: 03 Lookahead Scheduler + +## Status +Done + +## Goal + +Implement tick-based lookahead scheduling for 1-bar clip loop playback. + +## Context + +React UI timers are not accurate enough for musical timing. The audio engine needs scheduler logic that converts ticks to `AudioContext.currentTime` and schedules events ahead of playback. + +## Scope + +Included: +- Add tick-to-seconds and tick-to-audio-time utilities. +- Schedule tick-based events against `AudioContext.currentTime`. +- Support looping a 1-bar clip from tick 0 to tick 1920. +- Keep scheduler logic independent from React. +- Add scheduler state for play, stop, current tick, and loop range. +- Add tests for tick conversion and loop boundary behavior where practical. + +Excluded: +- Drum sequencer UI. +- Piano roll UI. +- Arrangement playback. +- User sample import. +- Effects. +- Advanced tempo automation. + +## Constraints +- Use PPQ 480 unless the data model is explicitly updated. +- Treat a 4/4 bar as 1920 ticks. +- Treat a 16-step grid step as 120 ticks. +- Do not rely on UI timers for exact playback. +- Avoid double-triggering events at loop boundaries. + +## Done when +- Scheduler can start and stop loop playback for a selected 1-bar clip model. +- Events are scheduled in advance using Web Audio time. +- Tick conversion utilities are covered by tests. +- Loop boundary behavior is documented and tested where practical. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Play a simple 1-bar event pattern and listen for steady looping. +- Confirm stop and restart do not leave stale scheduled behavior beyond the documented schedule-ahead window. + +## PR notes +- Summarize scheduler timing constants. +- Explain known limitations of manual audio timing verification. diff --git a/docs/features/04-drum-step-sequencer.md b/docs/features/04-drum-step-sequencer.md new file mode 100644 index 0000000..e815dd8 --- /dev/null +++ b/docs/features/04-drum-step-sequencer.md @@ -0,0 +1,61 @@ +# Feature: 04 Drum Step Sequencer + +## Status +Done + +## Goal + +Build a 16-step drum sequencer for the selected 1-bar hybrid clip. + +## Context + +The first clip editor needs drum programming that stores events in the serializable clip model and can integrate with the existing audio playback/scheduler when present. + +## Scope + +Included: +- Render a 16-step grid. +- Add initial lanes for kick, snare, closed hi-hat, and open hi-hat. +- Toggle steps on and off. +- Store events in the clip model using tick positions. +- Store lane sample assignment and lane order in the clip model. +- Allow lane sample selection from bundled drum samples. +- Allow vertical lane reordering. +- Use CSS Modules for styling. +- Use semantic buttons for step toggles. +- Integrate with the existing audio playback/scheduler if already present. + +Excluded: +- Piano roll. +- Arrangement view. +- Sample import. +- Advanced velocity editing unless simple velocity is already easy. +- Swing or groove editing. + +## Constraints +- Store step positions in ticks. +- Use 120 ticks per 16-step grid step for a 1-bar 4/4 clip at PPQ 480. +- Keep UI rendering separate from scheduler internals. +- Keep project data serializable. + +## Done when +- Users can toggle drum steps for the four initial lanes. +- Drum events are added to and removed from the selected clip model. +- Lane sample selection and lane order update the selected clip model. +- Step buttons have accessible names and visible focus states. +- Playback integration works if the scheduler exists. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Toggle steps in each lane and confirm model state updates. +- If playback exists, confirm enabled steps trigger the expected sounds in a loop. + +## PR notes +- Describe how lanes map to sample IDs. +- Note whether playback integration was included or deferred. diff --git a/docs/features/05-basic-piano-roll.md b/docs/features/05-basic-piano-roll.md new file mode 100644 index 0000000..6feede8 --- /dev/null +++ b/docs/features/05-basic-piano-roll.md @@ -0,0 +1,63 @@ +# Feature: 05 Basic Piano Roll + +## Status +Done + +## Goal + +Build a basic 1-bar piano roll for editing note events in a hybrid clip. + +## Context + +Hybrid clips should contain both drum events and pitched note events. The piano roll should represent MIDI notes with tick-based start and duration values. + +## Scope + +Included: +- Render a 1-bar piano roll grid. +- Add and delete notes. +- Move and resize notes if practical for the first version. +- Store MIDI note numbers. +- Store note start and duration in ticks. +- Add basic oscillator/synth playback or integrate with the existing audio engine. +- Use the initial C4-C5 pitch range from the bundled Iowa Piano sample set. +- Use CSS Modules for styling. +- Use inline styles for dynamic note geometry when useful. + +Excluded: +- Advanced editing tools. +- Automation. +- Arrangement view. +- Full sampler instrument. +- Quantize or swing unless already supported by utilities. +- MIDI file import/export. + +## Constraints +- Store all note timing in ticks. +- Keep note geometry derived from model data. +- Keep React rendering separate from exact audio scheduling. +- Keep project state serializable. + +## Done when +- Users can create and delete note events in a 1-bar clip. +- Note events store `midiNote`, `startTick`, `durationTicks`, and `velocity`. +- Basic playback is available through the audio engine path chosen for the milestone. +- Users can move created notes by dragging them on the piano roll grid. +- Styling uses CSS Modules, with inline styles only for computed geometry. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Add notes at multiple pitches and positions. +- Delete notes and confirm model state updates. +- Move notes and confirm pitch and tick position update. +- If playback exists, confirm note timing matches the grid well enough for the milestone. + +## PR notes +- Summarize the note editing interactions implemented. +- Note any editing operations deferred from the first version. diff --git a/docs/features/06-main-daw-ui-shell.md b/docs/features/06-main-daw-ui-shell.md new file mode 100644 index 0000000..051572a --- /dev/null +++ b/docs/features/06-main-daw-ui-shell.md @@ -0,0 +1,85 @@ +# Feature: 06 Main DAW UI Shell + +## Status +Done + +## Goal + +Convert the provided Tailwind-based DAW UI prototype into a working Vite + React + TypeScript UI shell implemented with CSS Modules. + +## Context + +The app needs a full-screen dark DAW-style interface before later sequencer, piano roll, and scheduler work is connected to real project/audio behavior. The provided prototype should be treated as a visual reference only. The project remains CSS Modules-based and must preserve the existing primitive-to-semantic design token policy. + +This task may be scheduled before the lookahead scheduler because it gives later drum sequencer and piano roll work a stable UI structure to attach to. + +## Scope + +Included: +- Full-screen dark app layout. +- Top transport bar. +- Left project and clip sidebar. +- Main drum step sequencer panel. +- Piano roll panel. +- Local UI state for play/stop, BPM, PAT/SONG mode, sidebar selection, and drum step toggles. +- Component-level CSS Modules. +- Shared design tokens in `src/styles/tokens.css`. +- Google Inter and Material Symbols font loading if needed. +- Documentation updates for UI reference policy. + +Excluded: +- Tailwind installation. +- Tailwind CDN or Tailwind utility classes in React components. +- New Web Audio engine work. +- Audio playback behavior. +- Lookahead scheduler. +- Persistence, import/export, or IndexedDB. +- Arrangement view. +- Real piano roll editing. +- Drag and drop. +- Mixer, routing, or effects. + +## Constraints +- Use the prototype as visual reference only. +- Do not add Tailwind or a React icon package. +- Preserve the primitive/semantic token model: + - primitive tokens define raw values; + - semantic tokens reference primitives; + - component CSS Modules use semantic tokens. +- Use inline styles only for dynamic editor geometry such as note positions, note widths, note top values, and grid coordinates. +- Do not remove the existing audio sample playback engine or bundled sample assets. +- Keep React UI separate from exact audio scheduling. +- Keep implementation scoped to a static/interactable shell with local state. + +## Done when +- The app renders a full-screen dark DAW interface without document-level scrolling. +- The top transport bar is visible. +- Play and stop buttons update transport visual state. +- BPM slider updates the displayed BPM. +- PAT/SONG toggle updates selected mode. +- Sidebar project, clip, and instrument items render, and selection state updates if practical. +- Drum sequencer renders four lanes and 16 step buttons per lane. +- Drum step buttons toggle active state using local React state. +- Piano roll renders a keyboard and an empty 32-column grid. +- No Tailwind dependency, Tailwind config, or Tailwind CDN is added. +- Components use semantic CSS Module class names instead of Tailwind utility-class-heavy markup. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Start `npm run dev` and inspect the UI. +- Confirm full-screen layout does not scroll the document. +- Confirm transport, BPM, PAT/SONG, sidebar selection, and drum step toggle interactions update visually. +- Confirm piano roll keyboard and empty 32-column grid render. +- Confirm no Tailwind package or CDN script was added. + +## PR notes +- Summarize how the Tailwind prototype was converted to CSS Modules. +- List major components added or changed. +- Mention that audio playback, real piano roll editing, scheduler behavior, and persistence are intentionally excluded. +- Include screenshots or a short recording if practical. diff --git a/docs/features/07-transport-pause-and-playhead.md b/docs/features/07-transport-pause-and-playhead.md new file mode 100644 index 0000000..581b58f --- /dev/null +++ b/docs/features/07-transport-pause-and-playhead.md @@ -0,0 +1,92 @@ +# Feature: 07 Transport Pause and Playhead + +## Status +Done + +## Goal + +Implement distinct pause, resume, and stop behavior for clip playback, and render a vertical runtime playhead in clip editors. + +## Context + +The current transport has only `playing` and `stopped` states. The play button can show a pause icon while playback is active, but pressing it stops playback in the same way as the stop button. + +Before piano roll playback and hybrid clip loop playback become more complex, the app needs a clearer runtime transport model: + +- Play from stopped starts at the beginning of the selected 1-bar clip. +- Pause stops playback while preserving the current musical tick. +- Resume continues from the paused tick. +- Stop stops playback and resets the playhead to the beginning. + +The visual playhead helps users understand where sequenced drum and note events are playing. It must not become the timing source for audio playback. + +## Scope + +Included: +- Add a `paused` transport state. +- Track the current runtime playhead position in ticks. +- Capture the current tick when pausing. +- Resume playback from the paused tick. +- Stop playback and reset the runtime playhead to tick 0. +- Render a vertical playhead in the piano roll editor. +- Render a playhead in the drum step sequencer if practical for the same implementation pass. +- Keep visual playhead animation driven by `requestAnimationFrame`. +- Keep exact audio scheduling driven by `AudioContext.currentTime`. +- Add focused tests for transport tick calculations, pause/resume offsets, and loop wrapping where practical. +- Update relevant docs if the audio engine API or transport state model changes. + +Excluded: +- Arrangement timeline playhead. +- Timeline scrubbing. +- Realtime recording. +- Automation. +- Tempo automation. +- Persisting runtime playhead position in project JSON. +- Full transport keyboard shortcut support. + +## Constraints + +- Store musical position in ticks, not seconds. +- Use PPQ 480, with the M1 1-bar loop spanning ticks 0 through 1920. +- Treat the paused playhead position as runtime state, not serializable project data. +- Do not store `AudioContext`, `AudioBuffer`, scheduler timers, source nodes, or playhead timers in project JSON. +- React UI may display transport state and playhead position, but must not own exact audio timing. +- The audio engine and scheduler must remain independent from React components. +- The visual playhead may be approximate; audible event timing must remain scheduled against `AudioContext.currentTime`. +- Use CSS Modules and semantic design tokens for playhead styling. +- Inline styles are acceptable for computed playhead geometry, such as `transform`, `left`, or width derived from ticks. + +## Done when + +- Transport state can represent `stopped`, `playing`, and `paused`. +- Pressing play from stopped starts playback from tick 0. +- Pressing pause while playing stops scheduling and preserves the current playhead tick. +- Pressing play from paused resumes playback from the paused tick. +- Pressing stop from playing or paused stops playback and resets the playhead to tick 0. +- The piano roll displays a vertical playhead aligned to the 1-bar grid. +- The playhead wraps cleanly at the 1-bar loop boundary. +- If included, the drum step sequencer displays a playhead or equivalent step-position indicator aligned to the 16-step grid. +- Existing drum step playback behavior still works. +- Tests cover pure transport/tick math where practical. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Start playback from stopped and confirm the playhead starts at the beginning. +- Pause playback and confirm the playhead stops at its current position. +- Resume playback and confirm it continues from the paused position. +- Stop playback and confirm the playhead returns to the beginning. +- Confirm the visual playhead and drum step indicator are hidden while stopped. +- Confirm the visual playhead loops across the 1-bar clip without jumping outside the editor grid. +- Confirm drum steps still trigger at the expected musical positions after pause/resume. + +## PR notes +- Describe the transport state model and runtime tick fields added or changed. +- Explain how pause differs from stop. +- Explain how the visual playhead reads timing state without driving audio scheduling. +- Note whether drum sequencer playhead display was included or deferred. diff --git a/docs/features/08-pitched-instruments-and-sustain.md b/docs/features/08-pitched-instruments-and-sustain.md new file mode 100644 index 0000000..7bb4239 --- /dev/null +++ b/docs/features/08-pitched-instruments-and-sustain.md @@ -0,0 +1,105 @@ +# Feature: 08 Pitched Instruments and Sustain + +## Status +Done + +## Goal + +Add selectable pitched instruments for piano roll playback: + +- `Default Synth`: keep the current oscillator-based instrument. +- `Iowa Piano`: play the bundled Iowa Piano WAV samples from their audible attack offsets. + +## Context + +The basic piano roll can now create, move, delete, and play tick-based note events. Its current playback path uses a simple oscillator synth. That behavior is useful because it can hold notes for their full `durationTicks`, but it does not sound like a piano. + +The project includes bundled Iowa Piano WAV samples for C4 through C5: + +```text +public/samples/pitched_instruments/Iowa_Piano/ +``` + +The next step is to keep the oscillator as a named instrument while adding a sample-based piano instrument that sounds more like a piano. Since the samples are not intended as full sampler-ready sustained loops yet, the first Iowa Piano implementation should avoid looping and leave advanced sustain for a follow-up sampler task. + +## Scope + +Included: +- Define separate pitched instrument choices for `Default Synth` and `Iowa Piano`. +- Preserve current oscillator playback behavior as `Default Synth`. +- Add sidebar instrument entries for selecting which pitched instrument's notes are visible in the piano roll. +- Play Iowa Piano notes using the bundled C4-C5 WAV files. +- Play Iowa Piano samples once from documented start offsets. +- Use a short gain attack and release envelope to reduce clicks. +- Stop active sample voices cleanly when transport stops. +- Keep musical time in ticks and convert note durations at playback time. +- Keep instrument/sample metadata serializable. +- Keep `AudioBuffer`, `AudioBufferSourceNode`, `OscillatorNode`, `GainNode`, and active voice state runtime-only. +- Add focused tests for instrument metadata, sample mapping, and sample start offset behavior where practical. +- Update relevant audio, data model, UI, and testing docs. + +Excluded: +- Full general-purpose sampler architecture. +- User sample import. +- Automatic loop point detection. +- Sustain looping, including crossfaded sustain looping. +- Advanced ADSR editing UI. +- Velocity editor UI. +- Arrangement playback. +- Persistence/export of full instrument presets beyond simple serializable IDs/metadata needed for this feature. + +## Constraints + +- React UI must not own exact audio timing. +- Schedule playback against `AudioContext.currentTime`. +- Store note start and duration in ticks. +- Keep project data serializable. +- Do not store `AudioBuffer` or Web Audio nodes in project JSON. +- Do not introduce runtime dependencies unless clearly justified. +- Use CSS Modules and semantic design tokens for UI changes. +- Preserve `Default Synth` behavior so piano roll playback still works even if sample loading fails. + +## Sustain Approach + +Start with a conservative sample-instrument strategy: + +- Each Iowa Piano sample zone may define `sampleStartSeconds` to skip leading silence before the audible attack. +- Iowa Piano sample zones should not define `loopStartSeconds` or `loopEndSeconds` in this task. +- Do not enable `AudioBufferSourceNode.loop` for Iowa Piano in this task. +- Use gain attack and release envelopes so note starts and note-offs do not click. +- Long Iowa Piano notes may naturally decay or end when the sample ends. Avoid repeated-strike artifacts over artificial sustain. + +Full sustain loop tuning remains incomplete and should move to a separate advanced sampler sustain issue. + +## Done when + +- Users can choose between `Default Synth` and `Iowa Piano` for piano roll playback. +- `Default Synth` keeps the current oscillator-style held-note behavior. +- `Iowa Piano` plays the bundled Iowa Piano sample for the note pitch when possible. +- Iowa Piano notes play from the audible attack offset and do not repeat when notes are longer than the sample tail. +- Stopping transport clears active Iowa Piano voices. +- Sample loading errors do not break the whole UI; the app should report an audio error or fall back in a controlled way. +- Instrument metadata and note events remain serializable. +- `Default Synth` and `Iowa Piano` notes can coexist in the same clip and play together. +- Relevant docs and tests are updated. + +## Verification +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Create notes in the piano roll. +- Confirm `Default Synth` still sounds like the current oscillator instrument. +- Select `Iowa Piano` and confirm notes sound piano-like. +- Create short and long Iowa Piano notes and confirm long notes do not retrigger or sound like repeated strikes. +- Stop playback and confirm no active notes remain stuck. +- Switch instruments and confirm the selected piano roll playback sound changes predictably. + +## PR notes +- Explain how `Default Synth` and `Iowa Piano` are represented. +- Document that advanced sampler sustain is deferred. +- Note that Iowa Piano sustain looping is intentionally deferred. +- Mention whether instrument selection is runtime-only or stored in serializable clip/project state. diff --git a/docs/features/09-sampler-advanced-sustain.md b/docs/features/09-sampler-advanced-sustain.md new file mode 100644 index 0000000..3dbe2f1 --- /dev/null +++ b/docs/features/09-sampler-advanced-sustain.md @@ -0,0 +1,130 @@ +# Feature: 09 Sampler Advanced Sustain + +## Status +Done + +## Goal + +Add a reusable sampler sustain path for sample-based pitched instruments. + +The first target is `Iowa Piano`, but the implementation should not be hard-coded to piano. Future bundled or imported sample instruments should be able to use the same sampler metadata and playback path. + +## Context + +`Default Synth` can hold long notes because it uses an oscillator. `Iowa Piano` currently plays bundled WAV samples once from explicit `sampleStartSeconds` offsets so the engine skips leading silence before the attack. + +Earlier sustain looping was deferred because simple loop points made long notes sound like repeated strikes. The next sampler step should support longer sample-based notes without coupling React UI to timing and without storing runtime audio objects in project data. + +This feature should add a general, metadata-driven sampler sustain mechanism. It is not a full sampler product. + +## Scope + +Included: +- Define serializable sampler zone metadata for sample start offset, loop region, loop mode, optional crossfade duration, and basic envelope values. +- Implement reusable sampled-note scheduling for pitched sample instruments. +- Use the general sampler path for `Iowa Piano`. +- Keep one-shot sample playback as the fallback when a zone has no valid sustain metadata. +- Support explicit loop metadata only. +- Use gain envelopes for attack and release so note starts and note-offs avoid clicks. +- Stop sustained sample voices cleanly when note duration ends, transport pauses, or transport stops. +- Keep note start and duration in ticks and convert to seconds at scheduling time. +- Keep decoded buffers, source nodes, gain nodes, and active voice state runtime-only. +- Add focused tests for sampler metadata validation, loop-region calculation, fallback behavior, and release timing helpers where practical. +- Update audio engine, data model, and testing docs. + +Excluded: +- User sample import UI. +- Visual loop point editor. +- Automatic loop point detection or sample analysis. +- Full sampler preset browser. +- Multisample velocity layers. +- Round-robin sample selection. +- Time-stretching. +- Pitch-shifting beyond playback-rate mapping from `rootMidiNote`. +- Arrangement playback changes. +- New runtime dependencies unless the implementation clearly justifies them. + +## Constraints + +- React UI must not own exact audio timing. +- Schedule playback against `AudioContext.currentTime`. +- Store musical event time in ticks, not seconds. +- Sample-local metadata such as start offsets, loop points, and envelope lengths may be stored in seconds. +- Keep project data serializable. +- Do not store `AudioBuffer`, `AudioNode`, object URLs, or decoded sample data in project JSON. +- Preserve `Default Synth` behavior. +- If sampler sustain metadata is invalid or sounds unsafe, fall back to one-shot sample playback instead of producing stuck notes. + +## Sampler Metadata Approach + +Sample zones may evolve toward this shape: + +```ts +export interface SampleZone { + sampleId: string; + midiNote: number; + rootMidiNote: number; + sampleStartSeconds?: number; + sampleEndSeconds?: number; + sustain?: SamplerSustainMeta; + envelope?: SamplerEnvelopeMeta; +} + +export interface SamplerSustainMeta { + mode: "none" | "forward-loop" | "crossfade-loop"; + loopStartSeconds?: number; + loopEndSeconds?: number; + crossfadeSeconds?: number; +} + +export interface SamplerEnvelopeMeta { + attackSeconds?: number; + releaseSeconds?: number; +} +``` + +Use these as planning types. The implementation may refine names, but any semantic changes should update `docs/data-model.md`. + +## Playback Approach + +- Load and decode the required sample before scheduling the note. +- Start playback at `sampleStartSeconds` when present. +- If `sustain.mode` is `none` or loop metadata is missing, play the sample once with the note envelope. +- If `sustain.mode` is `forward-loop`, use `AudioBufferSourceNode.loop`, `loopStart`, and `loopEnd` after validating the region. +- If `sustain.mode` is `crossfade-loop`, implement a simple scheduled crossfade strategy only if it can be done safely in this feature. Otherwise document the limitation and keep `forward-loop` as the first pass. +- Always apply release behavior at the note end so sustained voices do not stay stuck. +- Transport pause and stop must clear active sampler voices. + +## Done when + +- Sample-based pitched instruments use shared sampler sustain logic instead of Iowa-Piano-only sustain code. +- `Iowa Piano` can use explicit sampler sustain metadata through the shared path. +- Long Iowa Piano notes sustain more smoothly than one-shot playback when valid metadata is present. +- Long notes do not sound like repeated attacks as much as the available sample material allows. +- Invalid or missing loop metadata falls back to one-shot sample playback. +- Note release and transport stop clear sustained sample voices. +- Serializable metadata is documented and runtime audio objects remain runtime-only. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Create short and long Iowa Piano notes. +- Confirm short notes still start at the audible attack. +- Confirm long notes sustain without obvious repeated-strike artifacts when sustain metadata is valid. +- Confirm invalid or missing sustain metadata still plays as one-shot sample playback. +- Stop playback and confirm no sustained sample voice remains stuck. +- Confirm `Default Synth` behavior is unchanged. + +## PR notes + +- Explain the sampler metadata fields added or changed. +- Explain whether the implementation uses forward looping, crossfaded looping, or a documented fallback. +- List any Iowa Piano samples whose loop metadata still sounds weak. +- Mention manual audio checks and any remaining sample-material limitations. diff --git a/docs/features/10-tempo-control-and-bpm-slider.md b/docs/features/10-tempo-control-and-bpm-slider.md new file mode 100644 index 0000000..e8fc468 --- /dev/null +++ b/docs/features/10-tempo-control-and-bpm-slider.md @@ -0,0 +1,101 @@ +# Feature: 10 Tempo Control and BPM Slider + +## Status +Done + +## Goal + +Make the transport BPM slider control the app's actual playback tempo. + +The slider should not be only visual state. It should update the tempo used by the audio engine for drum and note scheduling. + +## Context + +The main UI shell added a BPM display and slider. Later playback features connected clip playback to the audio engine, but tempo control has not been planned as its own implementation task. + +The current app state has a `bpm` value and passes that value when playback starts. A focused tempo-control feature is still needed so BPM changes behave consistently across stopped, paused, and playing transport states. + +This should happen before advanced sampler sustain because tempo affects all scheduled drum and note events. + +## Scope + +Included: +- Treat BPM as the current project/transport tempo state. +- Keep tempo serializable as `tempoBpm` in project-level data or documented app-level project state until full project persistence exists. +- Keep the BPM slider and displayed BPM in sync. +- Start playback with the current BPM. +- Resume playback from pause using the current BPM. +- Apply BPM changes during active playback to future scheduler windows. +- Preserve or recompute the current runtime playhead tick consistently when tempo changes. +- Validate or clamp BPM to the supported UI range. +- Add focused tests for tempo updates, tick/time conversion, and scheduler behavior where practical. +- Update relevant data model, audio engine, UI, and testing docs. + +Excluded: +- Tempo automation lanes. +- Arrangement tempo maps. +- Smooth tempo ramps. +- Tap tempo. +- Metronome. +- Swing or groove timing. +- Keyboard shortcuts for tempo editing. +- Project export/import changes beyond documenting the serializable tempo field. + +## Constraints + +- React UI must not own exact audio timing. +- Musical event positions stay in ticks, not seconds. +- Schedule playback against `AudioContext.currentTime`. +- Tempo is serializable project data, but `AudioContext`, scheduler timers, Web Audio nodes, and runtime transport snapshots are runtime-only. +- Do not introduce runtime dependencies unless clearly justified. +- Keep the audio engine API small and typed. + +## Tempo Change Approach + +When the transport is stopped: +- Updating BPM should update the displayed value and the stored current tempo. +- The next play action should start with that tempo. + +When the transport is paused: +- Updating BPM should update the displayed value and stored current tempo. +- Resume should continue from the paused tick using the new tempo. + +When the transport is playing: +- Updating BPM should affect future scheduled windows. +- Prefer an audio-engine method that updates scheduler tempo while preserving the current runtime tick. +- If restarting the loop from the current tick is simpler and safer for the first implementation, keep the scheduling window short and document any audible limitation. + +Do not use React timers as the source of exact tempo or event timing. + +## Done when + +- Moving the BPM slider updates the displayed BPM. +- Starting playback uses the displayed BPM. +- Pausing and resuming playback uses the current BPM. +- Changing BPM during playback changes future drum and note scheduling. +- The visual playhead remains aligned with the audio engine transport snapshot after tempo changes. +- Invalid BPM values are rejected or clamped safely. +- Event positions remain tick-based and unchanged by tempo edits. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Move the BPM slider while stopped and confirm playback starts at that tempo. +- Move the BPM slider while paused and confirm resume uses the new tempo. +- Move the BPM slider while playing and confirm future steps and notes speed up or slow down. +- Confirm the playhead remains aligned with loop playback. +- Confirm drum and piano roll events still trigger at their tick positions. + +## PR notes + +- Explain how tempo is represented in state and passed to the audio engine. +- Explain how tempo changes are applied while stopped, paused, and playing. +- Note whether active playback updates scheduler tempo directly or restarts from the current tick. +- Mention any audible limitation when changing tempo during playback. diff --git a/docs/features/11-arrangement-view-ui-shell.md b/docs/features/11-arrangement-view-ui-shell.md new file mode 100644 index 0000000..3c605c0 --- /dev/null +++ b/docs/features/11-arrangement-view-ui-shell.md @@ -0,0 +1,133 @@ +# Feature: 11 Arrangement View UI Shell + +## Status +Done + +## Goal + +Add an arrangement view UI shell that appears when the transport mode is set to `SONG`. + +Keep the existing top transport bar and left project sidebar. The `PAT` mode should continue to show the current 1-bar hybrid clip editor. + +## Context + +The app already has a `PAT` / `SONG` toggle in the transport bar. `PAT` currently represents the focused pattern or clip editor workflow. `SONG` should start to represent the later arrangement workflow where clips are placed horizontally in time and vertically across tracks. + +A Google Stitch prototype provides a visual reference for the arrangement view. It shows: + +- Arrangement toolbar. +- Timeline ruler with bar numbers. +- Fixed left track headers. +- Scrollable grid with track lanes. +- Demo clip blocks placed on the timeline. +- Native horizontal scrolling for the timeline. + +The prototype uses Tailwind and includes layout issues, including track and clip alignment problems. Treat it as visual reference only. + +## Scope + +Included: +- Preserve the existing top transport bar. +- Preserve the existing left project sidebar. +- Render the current clip editor when mode is `PAT`. +- Render an arrangement view UI shell when mode is `SONG`. +- Add an arrangement toolbar with title and snap display. +- Add a timeline ruler with bar numbers. +- Add fixed-width track headers. +- Add a scrollable timeline grid with horizontal track lanes and vertical bar/beat lines. +- Add a few static/demo clip blocks to communicate intended layout. +- Keep native horizontal scrolling available when the timeline is wider than the viewport. +- Keep track headers, grid rows, and clip blocks vertically aligned through shared row-height constants. +- Keep ruler, grid columns, and clip left/width values horizontally aligned through shared timeline geometry constants. +- Use CSS Modules and semantic design tokens. +- Use inline styles only for dynamic arrangement geometry such as clip `left`, clip `width`, clip `top`, grid width, and playhead position. +- Update relevant UI/design docs. + +Excluded: +- Real arrangement data model changes. +- Persisted `ClipInstance` editing. +- Arrangement playback. +- Timeline playhead behavior beyond optional static visual placeholder. +- Drag and drop arrangement editing. +- Clip duplication, clip splitting, or clip creation in the arrangement. +- Real audio clips or generated waveforms. +- Track mute, solo, arm, or routing behavior beyond nonfunctional visual controls. +- Arrangement zoom implementation. +- Tailwind installation, Tailwind config, Tailwind CDN, or Tailwind utility-class-heavy React markup. + +## Constraints + +- React UI must not own exact audio timing. +- Store musical arrangement time in ticks when real arrangement data is introduced later. +- Keep current clip editor behavior unchanged in `PAT` mode. +- Preserve existing transport and sidebar components instead of duplicating them. +- Do not introduce runtime dependencies unless clearly justified. +- Follow the primitive-to-semantic token policy. +- Component CSS Modules should use semantic tokens, not raw primitive values, unless there is a documented exception. +- Do not copy the Stitch HTML directly into React. + +## UI Reference Policy + +Use the provided Stitch prototype for visual direction only: + +- Dark DAW workspace. +- Compact dense layout. +- Muted lime and teal accents. +- Thin borders and clear panel separation. +- Arrangement toolbar. +- Fixed track header column. +- Timeline ruler and grid. +- Clip blocks with small headers and simple content hints. + +Correct prototype issues during implementation. In particular, track headers, timeline lanes, and clip blocks must align exactly. + +## Geometry Approach + +Define shared arrangement geometry constants in component code or a local feature helper: + +- Track header width. +- Track row height. +- Ruler height. +- Bar width. +- Beat subdivision width. +- Timeline content width. + +Use those constants to compute both CSS grid/background dimensions and clip block positions. Do not duplicate unrelated magic numbers across track headers, grid rows, and clips. + +## Done when + +- Selecting `PAT` shows the existing hybrid clip editor. +- Selecting `SONG` shows the arrangement view UI shell. +- The existing transport bar and project sidebar remain visible in both modes. +- The arrangement view includes toolbar, ruler, track headers, timeline grid, demo clips, and bottom scrollbar/zoom placeholder. +- Track headers and timeline lanes align vertically. +- Clip blocks align to track rows and timeline columns. +- Horizontal scrolling, if present, is contained inside the arrangement panel. +- The app shell still avoids document-level scrolling. +- No Tailwind dependency, config, CDN script, or Tailwind utility-heavy React markup is added. +- Relevant docs are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Start the dev server. +- Confirm `PAT` mode still renders the current clip editor. +- Toggle to `SONG` and confirm the arrangement view renders. +- Confirm the existing transport and sidebar remain visible. +- Confirm track headers, grid rows, clip blocks, and ruler lines align. +- Confirm the page itself does not scroll. +- Confirm horizontal scrolling stays inside the arrangement panel. +- Confirm Tailwind was not added. + +## PR notes + +- Explain how the Stitch prototype was converted into CSS Modules. +- Mention that arrangement data model, persistence, editing, and playback are intentionally excluded. +- Describe how shared geometry constants keep track rows and clips aligned. +- Include screenshots or a short recording if practical. diff --git a/docs/features/12-sidebar-clip-instrument-management.md b/docs/features/12-sidebar-clip-instrument-management.md new file mode 100644 index 0000000..b61220f --- /dev/null +++ b/docs/features/12-sidebar-clip-instrument-management.md @@ -0,0 +1,152 @@ +# Feature: 12 Sidebar Clip and Instrument Management + +## Status +Done + +## Goal + +Make the left project sidebar a real clip and instrument management surface. + +Users should be able to create, select, rename, and delete clips. Within each clip, users should be able to add and remove available pitched instruments, then select an instrument to edit its piano roll notes. + +## Context + +The current sidebar already presents `CLIP 1`, a mandatory `Drums` item, and pitched instrument entries such as `Default Synth` and `Iowa Piano`. Selection is useful because the piano roll shows notes for the selected pitched instrument. + +The next step is to make that sidebar state editable instead of static. This requires a small serializable clip collection model, per-clip pitched instrument membership, and safe selection fallback when clips or instruments are removed. + +This feature is separate from arrangement editing. Creating clips here creates reusable 1-bar hybrid clips for the M1 clip editor; it does not place clip instances on an arrangement timeline. + +## Scope + +Included: +- Create new 1-bar hybrid clips from the sidebar. +- New clips start with only the mandatory `Drums` child item; pitched instruments must be added explicitly. +- Select clips from the sidebar and update the visible clip editor. +- Rename or otherwise edit clip names from the sidebar. +- Delete clips from the sidebar. +- Keep at least one clip available; do not allow the project to end in a zero-clip state. +- Show `+` and `-` icon buttons for add/delete actions where practical. +- Keep `Drums` as a mandatory child item for every hybrid clip. +- Show a scrollable or compact list of available pitched instruments when adding an instrument to a clip. +- Initial available pitched instruments are `Default Synth` and `Iowa Piano`. +- Add pitched instruments to the selected clip. +- Prevent duplicate pitched instrument entries in the same clip for the first version. +- Remove pitched instruments from the selected clip. +- Allow a clip to have zero pitched instruments; when this happens, the piano roll should show `-` as the instrument name and avoid creating pitched notes until an instrument is added. +- If removing a pitched instrument would delete that instrument's notes, require an explicit confirmation or a clearly documented safe fallback. +- Store clip list and per-clip pitched instrument membership in serializable state. +- Preserve `NoteEvent.instrumentId` ownership so multiple pitched instruments can coexist inside one hybrid clip. +- Update relevant data model, UI, and testing docs. + +Excluded: +- Arrangement clip placement or `ClipInstance` editing. +- Clip drag-and-drop reordering. +- Instrument drag-and-drop reordering. +- Drum lane add/delete. +- User-created custom instruments. +- User sample import. +- Full project export/import. +- IndexedDB autosave. +- Clip duplication. +- Keyboard shortcuts. +- Audio effects, mixer, or routing changes. + +## Constraints + +- Keep React UI separate from audio scheduling logic. +- Project and clip state must remain serializable. +- Do not store `AudioBuffer`, `AudioNode`, or decoded sample data in project state. +- Store musical event time in ticks. +- Use CSS Modules and semantic design tokens for sidebar UI changes. +- Use semantic buttons with accessible labels for icon-only `+` and `-` controls. +- Do not add a runtime dependency unless the implementation clearly justifies it. +- Do not remove the mandatory `Drums` child item in this first version. +- Do not close issue #30 until all acceptance criteria are implemented and verified. + +## Data Model Notes + +The implementation should introduce or formalize app-level project state containing an ordered clip list. A selected clip ID and selected sidebar item may remain runtime UI state. + +Each hybrid clip should store a serializable list of pitched instruments available inside that clip, for example: + +```ts +export interface Clip { + id: string; + name: string; + lengthTicks: Tick; + drumLanes: DrumLaneDefinition[]; + drumEvents: DrumEvent[]; + pitchedInstrumentIds: string[]; + noteEvents: NoteEvent[]; +} +``` + +`pitchedInstrumentIds` controls which pitched instrument entries are visible under the clip. `NoteEvent.instrumentId` still controls which instrument owns each note. + +Deleting a pitched instrument must deliberately handle note events with that `instrumentId`. The preferred first behavior is: + +- Ask for confirmation if notes exist for the instrument. +- On confirmation, remove the instrument entry and delete only note events owned by that instrument. +- If confirmation UI is not implemented, keep the delete action disabled while owned notes exist and explain why. + +## UI Notes + +The sidebar should keep the existing compact DAW style: + +- Clip rows show selection state. +- Clip rows expose an add-instrument `+` control and a delete-clip `-` control. +- The Clips section or project area exposes an add-clip `+` control. +- Pitched instrument rows expose a remove-instrument `-` control. +- Icon-only controls must have `aria-label`. +- `aria-expanded` should reflect expanded or collapsed clip groups when practical. +- The instrument picker should not be clipped by sidebar or workspace overflow. + +The first implementation may use inline rename controls, a small popover, or a simple focused editing state. A large modal is not required. + +## Done when + +- Users can create a new 1-bar hybrid clip from the sidebar. +- Users can select between clips and the main clip editor updates to the selected clip. +- Users can rename or edit a clip name. +- Users can delete a clip, with safe fallback selection and no zero-clip project state. +- Each clip shows its own mandatory `Drums` child item. +- Users can open an available-instrument list from the sidebar. +- Users can add `Default Synth` or `Iowa Piano` to a clip when it is not already present. +- Users cannot add duplicate pitched instrument entries to the same clip. +- Users can remove a pitched instrument safely. +- Removing a pitched instrument handles that instrument's note events according to the documented behavior. +- Pitched instrument selection updates the piano roll visible/editable instrument. +- Serializable state represents the clip list and per-clip pitched instrument membership. +- Existing drum sequencer, piano roll editing, transport, and scheduler behavior continue to work for the selected clip. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Add a new clip and confirm it appears in the sidebar. +- Select each clip and confirm the editor shows that clip's drum and note state. +- Rename a clip and confirm the label updates. +- Delete a non-selected clip and confirm the current selection remains valid. +- Delete the selected clip and confirm a nearby remaining clip becomes selected. +- Confirm the last remaining clip cannot be deleted or is immediately replaced with a valid empty clip. +- Add `Default Synth` and `Iowa Piano` to a clip from the instrument picker. +- Confirm unavailable or already-added instruments cannot be duplicated. +- Remove a pitched instrument with no notes. +- Remove a pitched instrument with notes and confirm the implemented confirmation or safe fallback behavior. +- Confirm `Drums` cannot be removed in this version. +- Confirm icon buttons have accessible names and visible focus states. + +## PR notes + +- Reference issue #30. +- Explain how clip list state is represented. +- Explain how per-clip pitched instrument membership is represented. +- Explain the note deletion behavior when removing a pitched instrument. +- Mention that arrangement placement, persistence, sample import, and custom instruments are intentionally deferred. diff --git a/docs/features/13-drum-step-subdivisions.md b/docs/features/13-drum-step-subdivisions.md new file mode 100644 index 0000000..5dc0b75 --- /dev/null +++ b/docs/features/13-drum-step-subdivisions.md @@ -0,0 +1,173 @@ +# Feature: 13 Drum Step Subdivisions + +## Status +Done + +## Goal + +Allow users to subdivide the existing 16 drum sequencer steps into smaller hit targets while preserving the familiar `1` through `16` primary step labels. + +The first version should support subdivision values `1`, `2`, and `3`: + +- `1`: current 16-step behavior. +- `2`: each primary step splits into two substeps. +- `3`: each primary step splits into three substeps. + +## Context + +The M1 drum sequencer currently treats a 4/4 one-bar clip as 16 primary steps. At PPQ 480, one primary step is 120 ticks. + +That works for basic 16th-note patterns, but real drum programming often needs faster hits, off-grid variations, or triplet-like placements. Users should be able to keep the simple 1-16 structure while making the clickable boxes below each numbered step more granular. + +This feature should keep the sequencer understandable. It is not a full groove, ratchet, probability, or velocity editor. + +## Scope + +Included: +- Add a drum step subdivision setting with options `1`, `2`, and `3`. +- Store the subdivision setting in serializable clip state. +- Apply the subdivision setting to all drum lanes in the selected clip for the first version. +- Keep the existing `1` through `16` primary step labels visible. +- Render substep buttons below each primary step label. +- Use one substep button per primary step when subdivision is `1`. +- Use two substep buttons per primary step when subdivision is `2`. +- Use three substep buttons per primary step when subdivision is `3`. +- Toggle drum events at tick positions derived from primary step index and substep index. +- Preserve existing 16-step patterns as subdivision `1` behavior. +- Keep existing drum lane sample selection and lane reordering behavior. +- Ensure playback schedules subdivided drum hits at the correct ticks. +- Use CSS Modules and semantic design tokens for UI changes. +- Add focused tests for subdivision tick math and drum event toggling where practical. +- Update relevant data model, audio engine, UI, and testing docs. + +Excluded: +- Per-lane subdivision settings. +- Swing or groove timing. +- Humanization. +- Velocity editing. +- Probability or conditional triggers. +- Ratchets, flams, rolls, or repeat effects. +- Piano roll grid changes. +- Arrangement editing or arrangement playback changes. +- Custom time signatures. +- Variable clip length. + +## Constraints + +- Store musical time in ticks, not seconds. +- Keep PPQ 480 and 4/4 one-bar length 1920 ticks. +- Keep React UI separate from exact audio scheduling. +- Schedule playback against `AudioContext.currentTime`. +- Project data must remain serializable. +- Do not store runtime audio objects in project JSON. +- Do not add runtime dependencies unless clearly justified. +- Preserve the CSS Modules and primitive/semantic token policy. +- Do not close issue #32 until all acceptance criteria are implemented and verified. + +## Tick Math + +Primary 16-step math stays unchanged: + +```text +1 bar = 1920 ticks +1 primary step = 1920 / 16 = 120 ticks +``` + +The subdivision tick size is: + +```text +substepTicks = 120 / subdivision +``` + +Supported values: + +```text +subdivision 1 -> substepTicks 120 +subdivision 2 -> substepTicks 60 +subdivision 3 -> substepTicks 40 +``` + +The event start tick is: + +```text +startTick = primaryStepIndex * 120 + substepIndex * substepTicks +``` + +Both indices are zero-based in code. Display labels remain one-based for primary steps. + +## Data Model Notes + +The implementation should store the selected drum subdivision in the clip model, for example: + +```ts +export interface Clip { + id: string; + name: string; + lengthTicks: Tick; + drumStepSubdivision: 1 | 2 | 3; + drumLanes: DrumLaneDefinition[]; + drumEvents: DrumEvent[]; + noteEvents: NoteEvent[]; +} +``` + +`DrumEvent.startTick` remains the source of truth for event timing. Do not store UI-only primary step or substep indexes as the event position. + +Changing the subdivision should not rewrite existing `DrumEvent.startTick` values. Events that land on visible substep positions for the selected subdivision should render as active. Events that do not align with the current subdivision should remain in the model and can be hidden, shown as off-grid markers, or left untouched by the first implementation. The preferred first behavior is to preserve them and document how they are displayed. + +## UI Notes + +The sequencer should keep a compact, readable layout: + +- The top row keeps primary step labels `1` through `16`. +- Each primary step column contains a nested substep group below the label. +- Substep buttons should be visually smaller than the current primary step buttons. +- Beat grouping should still be visible every four primary steps. +- Subdivision `2` and `3` can widen the sequencer horizontally if needed, but the layout should avoid document-level scrolling. +- If width becomes tight, the step sequencer panel may scroll internally. +- The subdivision selector should be near the drum sequencer header, using labels such as `1x`, `2x`, and `3x`. +- The active subdivision option should use `aria-pressed` or equivalent selected state. +- Each substep button should have an accessible label, for example `Toggle Kick step 5 substep 2`. + +Avoid making each lane header excessively narrow to compensate for more substep buttons. Preserving readable drum lane names is more important than fitting every subdivision into a fixed width. + +## Done when + +- Users can switch the drum sequencer subdivision between `1`, `2`, and `3`. +- Subdivision `1` matches the current 16-step behavior. +- Subdivision `2` renders two substep buttons under each primary step label. +- Subdivision `3` renders three substep buttons under each primary step label. +- Toggling a substep adds or removes a `DrumEvent` at the correct tick. +- Existing 16-step patterns still render and play correctly. +- Subdivided events play at the expected timing during loop playback. +- Loop boundaries do not double-trigger subdivided events. +- Changing subdivision does not unexpectedly delete existing drum events. +- Drum lane sample selection and lane reordering still work. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Select subdivision `1` and confirm the sequencer behaves like the current 16-step grid. +- Select subdivision `2` and toggle both substeps inside several primary steps. +- Select subdivision `3` and toggle all three substeps inside several primary steps. +- Confirm event timing by listening to loop playback at a moderate tempo. +- Confirm hits near the end of the bar loop correctly and do not double-trigger. +- Switch subdivision values and confirm existing events are preserved. +- Confirm lane sample selection still works. +- Confirm lane reordering still works. +- Confirm keyboard focus and accessible names work for subdivision controls and substep buttons. + +## PR notes + +- Reference issue #32. +- Explain how `drumStepSubdivision` is represented in clip state. +- Explain the tick conversion from primary step and substep to `DrumEvent.startTick`. +- Explain how existing events are preserved when subdivision changes. +- Mention any UI limitation around horizontal space or internal scrolling. diff --git a/docs/features/14-arrangement-mixer-panel-ui-shell.md b/docs/features/14-arrangement-mixer-panel-ui-shell.md new file mode 100644 index 0000000..f3fceac --- /dev/null +++ b/docs/features/14-arrangement-mixer-panel-ui-shell.md @@ -0,0 +1,170 @@ +# Feature: 14 Arrangement Mixer Panel UI Shell + +## Status +Done + +## Goal + +Add a bottom dock mixer panel to the `SONG` arrangement view. + +The mixer panel should establish the track-level mixing UI shape early: channel strips, volume faders, level meters, mute/solo controls, basic effect slots, and a master channel area. + +## Context + +Mixer controls are song-level and track-level controls. They belong with arrangement work rather than the focused `PAT` clip editor. + +The current arrangement view is a UI shell for timeline layout. The next useful shell-level addition is a bottom dock mixer panel that can later connect to real Web Audio routing. Starting with a panel now is preferable to adding tiny temporary controls to track headers and migrating them later. + +This feature should not implement the actual audio mixer yet. It should make the UI and component structure ready for future routing, metering, and effects work. + +## Scope + +Included: +- Render a bottom dock mixer panel inside `SONG` mode. +- Keep the existing top transport bar and left project sidebar. +- Keep the arrangement timeline visible above the mixer panel. +- Add track channel strips corresponding to the visible arrangement tracks. +- Include a master channel strip if practical. +- Each track channel strip should include: + - Track name. + - Volume fader. + - Level meter. + - Mute toggle. + - Solo toggle. + - Basic effect slot placeholder. +- Support local visual state for volume, mute, solo, and selected effect slot where useful. +- Use placeholder or mock level meter values only. +- Make channel strips horizontally scrollable if they do not fit. +- Use CSS Modules and semantic design tokens. +- Preserve the primitive-to-semantic token policy. +- Update relevant UI, architecture, audio, data model, and testing docs. + +Excluded: +- Real Web Audio mixer routing. +- Real gain node volume changes. +- Real mute or solo audio behavior. +- Real level metering from `AnalyserNode` or audio signal data. +- Real effect processing. +- Effect chain editing. +- Effect preset persistence. +- Arrangement data model editing. +- Track creation, deletion, or reordering. +- Mixer automation. +- Recording arm/input monitoring. +- Master export or offline rendering. + +## Constraints + +- React UI must not own exact audio timing. +- Do not store `AudioNode`, `AnalyserNode`, `GainNode`, or audio signal buffers in project JSON. +- The first mixer panel may use UI-local state; real mixer state persistence should wait for a model/routing feature. +- Do not introduce runtime dependencies unless clearly justified. +- Keep styling in CSS Modules. +- Component CSS should reference semantic tokens, not primitive tokens. +- Do not close issue #34 until all acceptance criteria are implemented and verified. + +## UI Notes + +Use a bottom dock layout in the arrangement view: + +```text +SONG workspace + Arrangement timeline + Bottom mixer panel + Track channel strips + Master channel strip +``` + +The panel should feel dense and DAW-like: + +- Compact channel strips with clear borders. +- Vertical volume faders. +- Thin level meters. +- `M` and `S` buttons for mute and solo. +- A small effect slot row such as `FX: None` or `FX: Basic`. +- Track color or accent indicators if already available. +- Horizontal scrolling inside the mixer panel when track count exceeds available width. + +The first implementation may use mock level values or static meter bars. If meter animation is added, it must be clearly visual-only and must not imply real audio metering. + +## Data Model Notes + +For this UI shell, mixer control values may remain local UI state or mock arrangement state. + +Do not introduce persistent mixer semantics unless the implementation also updates the data model intentionally. Future persisted mixer data may look like: + +```ts +export interface TrackMixerState { + trackId: string; + volumeDb: number; + pan?: number; + muted: boolean; + solo: boolean; + effectSlots: EffectSlotState[]; +} + +export interface EffectSlotState { + id: string; + kind: "placeholder" | "filter" | "delay" | "reverb" | string; + enabled: boolean; +} +``` + +Actual persisted mixer state should be introduced together with audio routing or a dedicated model feature, not silently in the UI shell. + +## Future Audio Routing Notes + +Future mixer routing should use Web Audio nodes owned by the audio engine, not React components. + +Expected future routing shape: + +- Track playback sources route into track gain nodes. +- Track gain nodes route into a master gain node. +- Mute and solo affect gain/routing in the audio engine. +- Level meters read runtime signal data from audio-engine-owned nodes. +- Effect slots map to audio-engine-managed effect nodes. + +This feature only prepares the UI surface for that future work. + +## Done when + +- `SONG` mode shows the arrangement timeline and a bottom dock mixer panel. +- `PAT` mode still shows the current clip editor. +- Existing transport and project sidebar remain visible. +- The mixer panel renders track channel strips aligned with the arrangement track concept. +- A master channel strip is present or the PR explains why it was deferred. +- Volume faders update visible/local state. +- Mute and solo buttons toggle visible/local state. +- Level meters render as placeholder or mock visual meters. +- Basic effect slots render as clearly nonfunctional placeholders. +- Channel strips remain usable when there are more tracks than horizontal space. +- The app shell avoids document-level scrolling. +- No real audio routing, gain node control, metering, or effects are implied by the UI. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Toggle to `SONG` mode and confirm the arrangement timeline remains visible. +- Confirm the bottom dock mixer panel appears below the timeline. +- Confirm track channel strips render with track names, faders, meters, mute, solo, and effect slots. +- Confirm faders update their visible value. +- Confirm mute and solo toggle visual state. +- Confirm level meters are visually present but not represented as real audio meters. +- Confirm channel strips scroll inside the mixer panel if needed. +- Confirm `PAT` mode remains unchanged. +- Confirm no document-level scrolling is introduced. + +## PR notes + +- Reference issue #34. +- Explain the mixer panel layout and component structure. +- State clearly that this is a UI shell only. +- Mention that real audio routing, metering, mute/solo behavior, and effects are deferred. +- Include screenshots or a short recording if practical. diff --git a/docs/features/15-wav-file-import-as-audio-clip.md b/docs/features/15-wav-file-import-as-audio-clip.md new file mode 100644 index 0000000..50b6529 --- /dev/null +++ b/docs/features/15-wav-file-import-as-audio-clip.md @@ -0,0 +1,163 @@ +# Feature: 15 WAV File Import as Audio Clip + +## Status +Done + +## Goal + +Allow users to import a local `.wav` file from the clip add flow and create an audio clip that can be selected from the project sidebar. + +The first version should make imported audio usable as clip content without adding arrangement placement, waveform editing, persistent imported-file storage, or time stretching. + +## Context + +The sidebar currently has a clip add button that directly creates a new hybrid clip. Users also need a way to bring their own audio material into the project. + +The new clip add flow should offer two choices: + +- `Build a clip`: the current behavior, creating a new editable hybrid clip. +- `Import a file`: opens a file picker for a local `.wav` file and creates an audio clip. + +Imported audio files are browser-local user files. The app may decode and cache runtime audio buffers for playback, but project JSON must store only serializable metadata and stable IDs. File bytes, `Blob`, `File`, `AudioBuffer`, object URLs, and audio nodes must remain outside project JSON. + +## Scope + +Included: +- Change the sidebar clip add button so it opens a compact choice menu. +- Add `Build a clip` as the existing new hybrid clip path. +- Add `Import a file` as a new path that opens a browser file picker. +- Accept `.wav` files only in the first version. +- Validate the selected file enough to reject non-WAV or undecodable files. +- Decode or inspect the selected WAV to collect duration metadata. +- Create a serializable audio clip entry that references imported audio metadata by stable ID. +- Display the imported audio clip in the sidebar clip list. +- Selecting the imported audio clip should show a simple audio clip detail or placeholder editor instead of the drum sequencer and piano roll. +- Show useful imported file metadata such as file name, display name, duration, and file type when practical. +- Add a simple loop preview play/stop control for the selected imported audio clip. +- Keep imported file bytes and decoded `AudioBuffer` data in runtime-only storage. +- Document that imported files are not persisted across refresh until IndexedDB or another persistence feature is implemented. +- Preserve the existing hybrid clip creation behavior under `Build a clip`. +- Use CSS Modules and semantic design tokens for UI changes. +- Add focused unit tests for model transformations and file-name/display-name helpers where practical. +- Add focused audio/import tests for validation or metadata helpers where practical. +- Update relevant data model, audio engine, UI, architecture, and testing docs. + +Excluded: +- Importing MP3, AIFF, FLAC, OGG, or other formats. +- Drag-and-drop file import. +- Sample slicing or waveform editing. +- Automatic tempo detection. +- Time stretching or pitch shifting imported audio. +- Adjusting clip duration during import. +- Arrangement placement or arrangement playback of imported audio. +- Resizing audio clip instances in the arrangement view. +- Persisting imported file bytes to IndexedDB. +- Project export/import that includes imported file bytes. +- Cloud upload or server-side storage. +- Recording audio in real time. + +## Constraints + +- React UI must not own exact audio timing. +- Musical arrangement positions should still use ticks. +- Imported audio source duration may be measured in seconds because it describes source media, not musical grid position. +- Project state must remain serializable. +- Do not store `File`, `Blob`, object URLs, `AudioBuffer`, `AudioNode`, or decoded PCM data in project JSON. +- Runtime audio caches and object URLs must have clear cleanup ownership. +- Do not add runtime dependencies unless a follow-up architecture decision justifies them. +- Do not implement arrangement duration resizing in this feature. + +## Data Model Notes + +The model should distinguish hybrid clips from imported audio clips. + +One possible shape: + +```ts +export type Clip = HybridClip | AudioClip; + +export interface AudioClip { + id: string; + kind: "audio"; + name: string; + sampleId: string; + sourceFileName: string; + durationSeconds: number; +} + +export interface SampleMeta { + id: string; + name: string; + durationSeconds?: number; + source: { + kind: "bundled" | "imported"; + fileName?: string; + mimeType?: string; + }; +} +``` + +Implementation names may differ, but the important rules are: + +- `AudioClip.sampleId` points to serializable sample metadata. +- Runtime file objects and decoded buffers are not stored in clip data. +- Imported audio clips may be lost on refresh until browser persistence is added. + +## Future Arrangement Duration Notes + +It is feasible to resize imported audio clips later in the arrangement view if arrangement placement is represented separately from the source clip. + +Preferred future shape: + +```ts +export interface ClipInstance { + id: string; + clipId: string; + trackId: string; + startTick: Tick; + lengthTicks: Tick; + sourceOffsetSeconds?: number; +} +``` + +For an imported audio clip, `lengthTicks` can control the visible and playable arrangement duration without rewriting the original audio file. + +Important limitation: without time stretching, resizing an audio clip instance should mean non-destructive trim/crop or extending silence after the source ends. Making the audio file musically stretch to a new duration is a separate time-stretching feature and may require a dedicated DSP approach or dependency. + +## Done when + +- The sidebar clip add button opens a menu with `Build a clip` and `Import a file`. +- `Build a clip` preserves the existing hybrid clip creation behavior. +- `Import a file` opens a file picker that accepts `.wav` files. +- Selecting a valid WAV creates an audio clip in the sidebar. +- Selecting the audio clip shows an audio clip detail or placeholder editor instead of drum/piano editors. +- The audio clip detail view can loop-preview and stop the imported WAV from the runtime cache. +- Invalid or undecodable files show a clear error and do not create broken clip state. +- Imported audio metadata is serializable. +- Runtime-only file, object URL, decoded buffer, and audio node data are not stored in project JSON. +- Relevant docs and tests are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Click the sidebar clip add button and confirm the menu shows `Build a clip` and `Import a file`. +- Choose `Build a clip` and confirm a normal hybrid clip is created. +- Choose `Import a file` and confirm the browser file picker accepts WAV files. +- Import a valid WAV and confirm a new audio clip appears in the sidebar. +- Select the imported audio clip and confirm the app shows audio clip information rather than the drum sequencer and piano roll. +- Use the preview controls and confirm the imported WAV loops and can be stopped. +- Try an invalid file and confirm the app shows an error without corrupting clip state. +- Refresh the page and confirm any non-persisted imported file limitation is understandable. + +## PR notes + +- Reference the GitHub issue for this feature. +- Explain how imported audio clip metadata is represented. +- Explain what runtime-only data is kept outside project JSON. +- State clearly that arrangement placement, duration resizing, time stretching, and persistent imported file storage are deferred. diff --git a/docs/features/16-arrangement-clip-placement-and-playback.md b/docs/features/16-arrangement-clip-placement-and-playback.md new file mode 100644 index 0000000..2e131b1 --- /dev/null +++ b/docs/features/16-arrangement-clip-placement-and-playback.md @@ -0,0 +1,174 @@ +# Feature: 16 Arrangement Clip Placement and Playback + +## Status +Done + +## Goal + +Allow users to place reusable clips on the `SONG` arrangement timeline and play the arranged song from the transport. + +This turns the arrangement view from a static shell into the first usable song-building surface. + +## Context + +The app already has: + +- A `PAT` mode for focused clip editing. +- A `SONG` mode arrangement UI shell. +- A left sidebar that manages clips and clip child items. +- Transport play, pause, resume, stop, playhead, and tempo behavior for clip playback. +- Planned WAV import as audio clips in `docs/features/15-wav-file-import-as-audio-clip.md`. + +Users now need to place clips horizontally in time and vertically across tracks. Playback in `SONG` mode should schedule the placed clip instances instead of looping only the selected `PAT` clip. + +## Scope + +Included: +- Make sidebar clips draggable into the arrangement timeline where practical. +- Create serializable `ClipInstance` entries when clips are placed. +- Store arrangement placement with `clipId`, `trackId`, `startTick`, and `lengthTicks`. +- Render arrangement clip blocks from project or app model state instead of static demo blocks. +- Move existing clip instances horizontally and vertically by drag where practical. +- Snap dropped and moved clip instances to the arrangement grid. +- Select and delete placed clip instances. +- Keep the existing arrangement track list and timeline layout aligned. +- Add a visual arrangement playhead in `SONG` mode. +- Let users set arrangement loop start and loop end on bar boundaries. +- Make transport play, pause, resume, and stop operate on arrangement playback when `SONG` mode is active. +- Schedule placed hybrid clips by expanding their drum and note events relative to each `ClipInstance.startTick`. +- Schedule placed audio clips when imported audio runtime data is available. +- Keep imported audio playback at original speed; no time stretching in this feature. +- Add focused tests for clip instance creation, moving, deletion, snapping, and scheduler event expansion where practical. +- Update relevant product, architecture, data model, audio, UI, and testing docs. + +Excluded: +- Time stretching or tempo matching imported audio. +- Clip instance resize handles. +- Clip splitting. +- Arrangement recording. +- Track creation, deletion, or reordering. +- Multi-select, copy/paste, duplicate shortcuts, or marquee selection. +- Arrangement loop region editing. +- Persistent project export/import beyond serializable in-memory model changes. +- IndexedDB persistence for imported audio bytes. +- Full mixer audio routing, real level metering, or effects. + +## Constraints + +- React UI must not own exact audio timing. +- Store arrangement positions and lengths in ticks. +- Use PPQ 480, where one 4/4 bar is 1920 ticks. +- Keep project data serializable. +- Do not store `AudioBuffer`, `AudioNode`, `File`, `Blob`, object URLs, scheduler timers, or drag runtime objects in project JSON. +- Playback must schedule audio against `AudioContext.currentTime`. +- Audio clip source duration may be seconds because it describes media length, but arrangement placement still uses ticks. +- Imported audio clips must fail clearly if their runtime file data is missing. +- Use CSS Modules and semantic design tokens for UI changes. +- Preserve existing `PAT` mode clip editing behavior. + +## Data Model Notes + +The arrangement should use serializable clip instances: + +```ts +export interface ClipInstance { + id: string; + clipId: string; + trackId: string; + startTick: Tick; + lengthTicks: Tick; + sourceOffsetSeconds?: number; +} +``` + +For hybrid clips, the first implementation should use the clip's `lengthTicks` as the default instance length. Hybrid clip events play once relative to the instance start. Repeating a pattern should be represented by multiple placed instances until a later feature adds looping or repeat handles. + +For imported audio clips, `lengthTicks` controls the visible and scheduled clip-instance duration. Without time stretching, playback uses the decoded audio at original speed: + +- If the instance is shorter than the source, playback is cropped. +- If the instance is longer than the source, the remaining duration is silence. +- If runtime imported file data is unavailable after refresh, the clip instance should remain visible but audio playback should report a clear missing-source limitation. + +The first implementation may seed a small set of arrangement tracks in app state. Track creation, deletion, and reordering are separate tasks. + +## UI Notes + +Use the existing `SONG` arrangement layout: + +- Sidebar clip rows are drag sources. +- Arrangement track lanes are drop targets. +- Placed clips render as timeline blocks. +- Dragging a placed clip moves it to another snapped start time or track. +- Selecting a placed clip shows a clear selected state. +- Right-clicking a placed clip instance deletes only that arrangement placement, not the source clip from the sidebar. + +Snap should start simple. Prefer beat-level snapping, using 480 ticks, unless the existing arrangement snap control already supports a narrower value. Later features may add user-selectable snap resolution. + +If browser drag-and-drop is awkward for track timeline geometry, pointer-based dragging is acceptable. The feature should still behave like drag placement from the user's perspective. + +## Audio Notes + +`SONG` playback should schedule arrangement events, not selected-clip loop events. + +Selecting a sidebar clip or instrument while `SONG` mode is playing should only change UI selection. It must not replace active arrangement playback with selected `PAT` clip events or stop playback just because an imported audio clip was selected. + +The audio engine or feature orchestration should expand clip instances into runtime scheduler events: + +- Hybrid clip drum events: `instance.startTick + drumEvent.startTick`. +- Hybrid clip note events: `instance.startTick + noteEvent.startTick`. +- Audio clips: source starts at `instance.startTick`, optionally from `sourceOffsetSeconds`. + +The arrangement transport may initially play linearly from tick 0 through the end of the last clip instance and then stop. Arrangement loop ranges can be added later. + +The first implementation may reuse the existing lookahead loop scheduler over the visible arrangement range while the arrangement-specific one-shot/linear transport matures. If so, document the limitation in the PR and keep the arrangement event expansion independent from React. + +When an arrangement loop range is set, `SONG` playback should use that range for `loopStartTick` and `loopEndTick`. + +Events already scheduled inside the lookahead window may still play briefly after edits, pause, or stop. Keep the scheduling window short enough for interactive editing. + +## Done when + +- Users can drag a sidebar clip into a `SONG` arrangement track. +- A placed clip block appears at the snapped time and correct track. +- Placed clip blocks are backed by serializable `ClipInstance` data. +- Static demo arrangement clips are removed or replaced by seeded serializable model state. +- Users can move a placed clip instance to another snapped time or track. +- Users can select and delete a placed clip instance without deleting the source clip. +- `SONG` transport playback schedules placed clip instances. +- Pause, resume, and stop work in `SONG` mode. +- A visual arrangement playhead follows playback and resets or hides appropriately when stopped. +- Loop start and loop end can be set on bar boundaries and `SONG` playback loops that range. +- Hybrid clip drum and note events play at their arrangement positions. +- Audio clip instances play when runtime imported audio data is available. +- Missing imported audio runtime data is reported clearly instead of silently failing. +- `PAT` mode clip editing and clip playback still work. +- Relevant tests and docs are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Switch to `SONG` mode. +- Drag a sidebar clip into an arrangement track. +- Confirm the placed clip aligns to the snapped grid. +- Drag the placed clip to another time and track. +- Select and delete the placed clip. +- Place multiple clips across tracks and press play. +- Confirm the arrangement playhead roughly matches audible playback. +- Confirm pause, resume, and stop behavior in `SONG` mode. +- Confirm `PAT` mode still edits and plays the selected clip. +- If WAV import is implemented, place an imported audio clip and confirm it plays when runtime data is available. +- Refresh the page and confirm missing imported audio data is handled clearly if persistence is not implemented. + +## PR notes + +- Reference issue #41. +- Explain the `ClipInstance` model changes or model usage. +- Explain how sidebar drag placement and arrangement move/delete work. +- Explain how `SONG` playback differs from `PAT` clip loop playback. +- State clearly that time stretching, resizing, arrangement loop regions, and persistent imported audio bytes are deferred. diff --git a/docs/features/17-mixer-audio-routing-and-track-controls.md b/docs/features/17-mixer-audio-routing-and-track-controls.md new file mode 100644 index 0000000..6b80e30 --- /dev/null +++ b/docs/features/17-mixer-audio-routing-and-track-controls.md @@ -0,0 +1,189 @@ +# Feature: 17 Mixer Audio Routing and Track Controls + +## Status +Done + +## Goal + +Connect the existing `SONG` mixer panel to real audio behavior. + +The first functional mixer should support per-track routing, track volume, master volume, mute, solo, and runtime level meters for arrangement playback. + +## Context + +The current mixer panel is intentionally a UI shell. It renders channel strips, faders, mute/solo controls, level meters, effect-slot placeholders, and a master strip, but those controls do not affect Web Audio playback. + +The app now needs a focused mixer-routing step so arrangement playback can be balanced across tracks. This should happen after track-aware arrangement playback exists, because mixer routing needs each scheduled source to know which arrangement track it belongs to. + +## Scope + +Included: +- Add serializable mixer state for arrangement tracks. +- Add serializable master mixer state. +- Route arrangement playback sources through track gain nodes. +- Route track outputs through a master gain node before `AudioContext.destination`. +- Make track volume faders control real track gain. +- Make the master fader control real master gain. +- Make mute and solo buttons affect actual track audibility. +- Add runtime level metering for track channels and master output where practical. +- Update the existing mixer panel to display live or near-live meter values. +- Keep existing effect slots visible as placeholders. +- Add focused tests for mixer state transformations, decibel-to-gain conversion, and mute/solo effective-gain logic. +- Update relevant architecture, data model, audio engine, UI, and testing docs. + +Excluded: +- Audio effects or effect chain processing. +- Effect preset persistence. +- Mixer automation. +- Pan controls. +- Sends, buses, groups, sidechain routing, or recording arm. +- Offline export or render-to-file behavior. +- Persistent project export/import beyond documenting serializable state shape. +- Reworking the mixer panel visual design beyond what is needed to wire controls. +- Runtime dependencies. + +## Constraints + +- This feature depends on arrangement playback having track-aware scheduled sources. +- React UI must not own exact audio timing. +- Web Audio nodes, `AnalyserNode` instances, meter buffers, active source nodes, and routing graph objects are runtime-only. +- Project data may store mixer settings such as volume, mute, and solo, but not runtime audio objects. +- Use CSS Modules and semantic design tokens. +- Preserve the primitive-to-semantic token policy. +- Keep effect slots clearly nonfunctional until an effects feature implements real processing. + +## Data Model Notes + +Track mixer settings should be serializable. + +Recommended first shape: + +```ts +export interface TrackMixerState { + trackId: string; + volumeDb: number; + muted: boolean; + solo: boolean; +} + +export interface MasterMixerState { + volumeDb: number; +} +``` + +Track mixer state may live on each `Track` or in a project-level map keyed by `trackId`. Choose the representation that best fits the existing model implementation, but keep it serializable and easy to test. + +Suggested default values: + +```text +track volumeDb = 0 +track muted = false +track solo = false +master volumeDb = 0 +``` + +Use a safe fader range such as `-60 dB` to `+6 dB`, with `-60 dB` treated as near-silent. A later feature can add a stricter `-Infinity` representation if needed. + +Mute and solo should have deterministic effective audibility: + +```text +anySolo = at least one track has solo = true +trackAudible = (!anySolo || track.solo) && !track.muted +``` + +If a track is both muted and soloed, muted wins and the track remains silent. This rule is simple to test and avoids hidden UI behavior. + +## Audio Engine Notes + +The audio engine should own the routing graph: + +```text +scheduled source + -> track gain node + -> optional track meter analyser + -> master gain node + -> optional master meter analyser + -> AudioContext.destination +``` + +Track gain nodes should be keyed by stable `trackId`. When arrangement playback schedules a source, that source should connect to the appropriate track channel instead of directly to the destination. + +Mixer control updates should be handled through a small typed API, for example: + +```ts +setTrackVolume(trackId, volumeDb) +setTrackMute(trackId, muted) +setTrackSolo(trackId, solo) +setMasterVolume(volumeDb) +getMixerLevels() +``` + +Implementation names may differ, but React components should not manipulate Web Audio nodes directly. + +Level meters are runtime display data. The UI may poll meter snapshots with `requestAnimationFrame` or subscribe through a lightweight audio-engine callback. Meter display timing does not drive audio scheduling. + +## UI Notes + +Use the existing bottom dock mixer panel. + +Functional behavior: + +- Track faders update track volume. +- Master fader updates master volume. +- `M` toggles real mute. +- `S` toggles real solo. +- Track meters respond to that track's audio signal. +- Master meter responds to the summed output signal. + +The UI should still be clear when no arrangement playback is active: + +- Meters may sit at zero. +- Faders and mute/solo buttons remain editable. +- Settings should affect the next playback start. + +Effect slots should remain visibly disabled or placeholder-only. Do not imply that effects are active. + +## Done when + +- Arrangement playback routes each scheduled source through a track mixer channel. +- Track volume faders change the audible level of their own track during playback. +- The master fader changes overall output level during playback. +- Muting a track makes that track silent. +- Soloing one or more tracks makes only audible soloed tracks play. +- If a track is both muted and soloed, it remains silent. +- Track meters show runtime audio level for active tracks. +- The master meter shows runtime output level. +- Stopping playback clears or decays visible meter levels without stuck values. +- Mixer settings are represented as serializable state where appropriate. +- Runtime Web Audio graph objects are not stored in project JSON or React component state. +- Existing `PAT` mode playback still works. +- Relevant tests and docs are updated. + +## Verification + +Run: +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +Manual check: +- Place clips on at least two arrangement tracks. +- Start `SONG` playback. +- Move a track fader and confirm only that track changes level. +- Move the master fader and confirm overall output changes. +- Mute a track and confirm it becomes silent. +- Solo one track and confirm non-solo tracks become silent. +- Solo multiple tracks and confirm only those tracks remain audible. +- Mute a soloed track and confirm it remains silent. +- Confirm track and master meters respond to playback. +- Stop playback and confirm meters settle instead of staying stuck. +- Confirm `PAT` mode playback still works. + +## PR notes + +- Reference issue #43. +- Explain the mixer state representation. +- Explain how scheduled sources route through track and master gain nodes. +- Explain the mute/solo effective-audibility rule. +- State clearly that effects, automation, pan, sends, buses, and export rendering are deferred. diff --git a/docs/features/18-indexeddb-project-persistence.md b/docs/features/18-indexeddb-project-persistence.md new file mode 100644 index 0000000..d3d3fd6 --- /dev/null +++ b/docs/features/18-indexeddb-project-persistence.md @@ -0,0 +1,81 @@ +# Feature: 18 IndexedDB Project Persistence + +## Status +Done + +## Goal + +Persist the user's current project in the browser so a refresh or reopened tab can restore clips, arrangement data, mixer settings, imported sample metadata, and imported sample audio bytes. + +## Context + +The app is browser-first and project data must remain serializable. Runtime audio objects such as `AudioContext`, `AudioBuffer`, node graphs, object URLs, and decoded sample caches must not be stored in project JSON. + +Imported audio files need separate durable storage because object URLs and decoded buffers are session-only. IndexedDB is the first local persistence target. + +## Scope + +Included: + +- Add an IndexedDB persistence layer under `src/persistence/`. +- Store a versioned project document for the active project. +- Store imported sample metadata in serializable project state. +- Store imported sample file bytes or blobs in IndexedDB using stable sample IDs. +- Restore the last active project on app startup. +- Rebuild runtime audio caches from stored blobs only when needed. +- Add debounced autosave for project edits. +- Show minimal save status, such as saved, saving, or save failed. +- Add a baseline migration/version field for stored project documents. +- Handle unavailable storage or quota errors with visible user feedback. + +Excluded: + +- Cloud sync. +- User accounts. +- Multi-device collaboration. +- A full project browser or multi-project dashboard. +- File-system project export/import. +- Final audio rendering/export. +- Storing `AudioBuffer`, `AudioNode`, `File`, or object URL values in project JSON. + +## Constraints + +- Keep project JSON serializable. +- Use stable IDs to connect project sample metadata to IndexedDB blobs. +- Keep persistence separate from React rendering and audio scheduling. +- Do not make the audio engine depend on IndexedDB. +- Do not introduce a runtime dependency unless the implementation clearly needs it and the PR explains why. +- Tests may use lightweight mocks or adapters for IndexedDB behavior. + +## Done when + +- Refreshing the browser restores the active project state. +- Imported WAV clips can still be played after refresh if their blobs were saved successfully. +- Autosave does not block UI interactions. +- Save failures are visible and do not corrupt in-memory state. +- Stored project records include a schema version. +- Runtime audio caches are rebuilt from persistent data rather than serialized directly. + +## Verification + +Run: + +- `npm run typecheck --if-present` +- `npm run lint --if-present` +- `npm run test --if-present` +- `npm run build --if-present` + +Manual check: + +- Create or modify a clip. +- Import a WAV file. +- Place clips in the arrangement. +- Refresh the page. +- Confirm the project, clips, placement, and imported audio references are restored. +- Confirm the console has no IndexedDB or decoding errors. + +## PR notes + +- Summarize the IndexedDB stores and schema version. +- Explain what is persisted and what remains runtime-only. +- Mention any browser storage limitations or quota behavior. diff --git a/docs/features/19-variable-hybrid-clip-length.md b/docs/features/19-variable-hybrid-clip-length.md new file mode 100644 index 0000000..08a1812 --- /dev/null +++ b/docs/features/19-variable-hybrid-clip-length.md @@ -0,0 +1,82 @@ +# Feature: 19 Variable Hybrid Clip Length + +## Status +Done + +## Goal + +Allow hybrid clips to be 1, 2, or 4 bars long instead of always being fixed to 1 bar. + +## Context + +The current first milestone focuses on a 1-bar hybrid clip editor. As patterns become more musical, users need longer clips for drum variations and melodic phrases. + +Musical time is stored in ticks with PPQ 480. In 4/4: + +- 1 beat = 480 ticks +- 1 bar = 1920 ticks +- 2 bars = 3840 ticks +- 4 bars = 7680 ticks + +## Scope + +Included: + +- Add a clip length control for 1, 2, and 4 bars. +- Store the selected length as `lengthTicks` in the clip model. +- Derive editor grids from clip length rather than hard-coded 1-bar constants. +- Extend the drum step sequencer across the selected clip length using the existing 16th-note step behavior. +- Extend the piano roll grid across the selected clip length. +- Keep drum and note event start times and durations in ticks. +- Make clip loop playback use the selected clip length. +- Make arrangement clip instances default to the source clip length. +- Prevent or clearly confirm shortening a clip when events would fall outside the new length. + +Excluded: + +- Arbitrary clip lengths. +- Time signatures other than 4/4. +- Per-clip tempo. +- Audio time-stretching. +- Arrangement clip trimming. +- Clip duplication workflows beyond what already exists. + +## Constraints + +- Do not store seconds as the source of truth. +- Do not duplicate grid constants across components when a shared utility can derive them. +- Keep editor rendering separate from audio scheduling. +- Preserve existing 1-bar behavior. +- If shortening requires destructive deletion of events, require explicit user confirmation or block the change with a clear message. + +## Done when + +- A user can choose 1, 2, or 4 bars for a hybrid clip. +- Drum and piano roll editors reflect the selected clip length. +- Events cannot silently exist outside the clip length. +- Clip playback loops over the selected clip length. +- Arrangement placement uses the clip's actual length. +- Existing 1-bar clips still load and behave correctly. + +## Verification + +Run: + +- `npm run typecheck --if-present` +- `npm run lint --if-present` +- `npm run test --if-present` +- `npm run build --if-present` + +Manual check: + +- Create or edit a 1-bar clip. +- Change it to 2 bars and add events in bar 2. +- Change it to 4 bars and add events in bars 3 or 4. +- Confirm playback loops over the full clip length. +- Confirm shortening does not silently drop events. + +## PR notes + +- Summarize how clip length is represented in ticks. +- Note how grid rendering and scheduler loop length were updated. +- Call out any remaining UX limitations for long clips. diff --git a/docs/features/20-adjustable-arrangement-length.md b/docs/features/20-adjustable-arrangement-length.md new file mode 100644 index 0000000..f97c6e1 --- /dev/null +++ b/docs/features/20-adjustable-arrangement-length.md @@ -0,0 +1,75 @@ +# Feature: 20 Adjustable Arrangement Length + +## Status +Done + +## Goal + +Allow users to increase or decrease the arrangement length instead of keeping the arrangement fixed at 16 bars. + +## Context + +The current arrangement shell uses a fixed 16-bar timeline. Users need to extend the song as they build a project and reduce the length when the project is shorter. + +The arrangement length should be serializable project state and should drive the ruler, grid, horizontal scroll area, loop boundaries, and export duration. + +## Scope + +Included: + +- Add `arrangement.lengthBars` or an equivalent serializable field to project state. +- Default new projects to 16 bars. +- Add arrangement toolbar controls to increment and decrement the arrangement length. +- Derive ruler labels, grid width, loop bounds, and playback bounds from arrangement length. +- Keep a minimum arrangement length of 1 bar. +- Use a practical maximum length for the first implementation, such as 128 bars. +- Prevent or clearly confirm reducing arrangement length when clip instances would extend beyond the new end. +- Clamp or validate arrangement loop ranges so they remain inside the arrangement length. + +Excluded: + +- Infinite timeline behavior. +- Zoom controls. +- Track count management. +- Clip trimming or time-stretching. +- Export rendering. +- Advanced timeline virtualization unless performance requires it. + +## Constraints + +- Store arrangement length in musical units and derive ticks from PPQ/bar math. +- Keep the model serializable. +- Avoid coupling timeline rendering to audio scheduling internals. +- Do not delete clip instances outside a shortened range without explicit user confirmation. + +## Done when + +- The arrangement can be expanded beyond 16 bars. +- The arrangement can be reduced when safe. +- Ruler labels and grid lines match the selected arrangement length. +- Loop start/end controls cannot move outside the arrangement. +- Playback uses the current arrangement length and loop range. +- The selected arrangement length survives project persistence once persistence exists. + +## Verification + +Run: + +- `npm run typecheck --if-present` +- `npm run lint --if-present` +- `npm run test --if-present` +- `npm run build --if-present` + +Manual check: + +- Increase the arrangement length and confirm bars appear past 16. +- Decrease the arrangement length and confirm the ruler/grid update. +- Try reducing length below existing clip instances and confirm the app blocks or confirms the destructive action. +- Confirm loop handles stay within the arrangement bounds. +- Confirm playback still loops over the selected range. + +## PR notes + +- Summarize the model field used for arrangement length. +- Explain the chosen min/max bar limits. +- Note how shortening is handled when clips exist past the new end. diff --git a/docs/features/21-arrangement-wav-export.md b/docs/features/21-arrangement-wav-export.md new file mode 100644 index 0000000..733b25a --- /dev/null +++ b/docs/features/21-arrangement-wav-export.md @@ -0,0 +1,78 @@ +# Feature: 21 Arrangement WAV Export + +## Status +Done + +## Goal + +Render the current arrangement to a downloadable WAV file. + +## Context + +Users eventually need a final audio file from the arrangement they build in the browser. WAV is the first export target because it can be generated directly from PCM audio without adding a codec dependency. + +Export should use the audio engine's musical data and scheduling rules, not UI timers. + +## Scope + +Included: + +- Add an export action for the current arrangement. +- Render the full arrangement length to audio, starting at bar 1. +- Use `OfflineAudioContext` or an equivalent offline audio rendering path. +- Include arranged hybrid clips, imported audio clips, synth/sampler instruments, and mixer routing that exist at the time this feature is implemented. +- Apply track mute, solo, volume, and master gain if mixer routing exists. +- Encode rendered PCM to a downloadable WAV Blob. +- Use a predictable first export format, such as stereo 44.1 kHz 16-bit PCM WAV. +- Show export progress, busy state, and failure feedback. +- Block export with a clear error if required sample data is unavailable. + +Excluded: + +- MP3, FLAC, AAC, or compressed export. +- Stem export. +- Realtime recording. +- Mastering processors such as loudness normalization or limiting. +- Cloud upload. +- Exporting only selected clips. +- Background worker rendering unless needed for responsiveness. + +## Constraints + +- Do not rely on React timers or visual playheads for export timing. +- Do not mutate live playback state while exporting. +- Keep offline rendering code separate from React components. +- Keep exported audio deterministic for the same project data and sample assets where practical. +- Do not add an encoder dependency unless native WAV encoding is insufficient and the PR justifies the dependency. + +## Done when + +- A user can click export and download a WAV file. +- The WAV duration matches the arrangement length. +- Arranged clips render at their expected positions. +- Missing sample data produces a clear export error. +- Live playback can still work after export completes or fails. +- The PR documents which instruments and clip types are included in the first export pass. + +## Verification + +Run: + +- `npm run typecheck --if-present` +- `npm run lint --if-present` +- `npm run test --if-present` +- `npm run build --if-present` + +Manual check: + +- Build a short arrangement with at least one drum clip and one pitched clip. +- Export WAV. +- Open the WAV in a media player or DAW. +- Confirm the file duration and clip timing are correct. +- Confirm export failure is clear when a required imported sample is missing. + +## PR notes + +- Summarize the offline rendering path. +- State the WAV format details. +- Mention unsupported clip or instrument types, if any. diff --git a/docs/features/22-multi-project-management.md b/docs/features/22-multi-project-management.md new file mode 100644 index 0000000..a5aa087 --- /dev/null +++ b/docs/features/22-multi-project-management.md @@ -0,0 +1,130 @@ +# Feature: 22 Multi-project Management + +## Status +Done + +## Goal + +Allow users to manage more than one browser-local project instead of being limited to a single hard-coded active project. + +## Context + +The current app persists one active project in IndexedDB using a fixed `ACTIVE_PROJECT_ID`. The transport bar displays `Project 1`, and there is no project-level UI for creating, switching, renaming, or deleting separate songs. + +Multiple local projects should stay browser-first and lightweight. This feature is not a cloud account system or a file browser. It should extend the existing IndexedDB persistence model so users can keep separate sketches in the same browser profile. + +## Scope + +Included: + +- Store multiple serializable project documents in IndexedDB. +- Store the active project ID separately from project documents. +- Restore the last active project on app startup. +- Add a transport bar project menu that shows the active project name. +- Support creating a new blank project. +- Support switching between existing projects. +- Support renaming the active project. +- Support deleting a project with confirmation. +- Stop playback and audio preview before switching projects. +- Keep imported sample blobs scoped to the owning project so projects cannot collide on sample IDs. +- Migrate the existing single active project into the multi-project shape where practical. + +Excluded: + +- Cloud sync or user accounts. +- Cross-device collaboration. +- File-system project export/import. +- Project templates beyond the current default blank project. +- Project duplication unless it is trivial after the base workflow exists. +- A full project dashboard page. +- Sharing projects between browsers or devices. + +## Constraints + +- Project data must remain serializable. +- Runtime objects such as `AudioContext`, `AudioBuffer`, `File`, object URLs, active source nodes, and mixer nodes must not be stored in project JSON. +- Switching projects must not let pending autosave write the old project into the new project ID. +- Persistence code should stay separate from React rendering and audio scheduling. +- The audio engine must not depend on IndexedDB or project menu UI. +- Use CSS Modules and semantic design tokens for project menu UI. +- Do not introduce a runtime dependency for IndexedDB or menu behavior unless the implementation clearly justifies it. + +## Proposed Model + +Use a stable project ID per project instead of one fixed `ACTIVE_PROJECT_ID`. + +Illustrative shape: + +```ts +export interface ProjectSummary { + id: string; + name: string; + createdAt: number; + updatedAt: number; +} + +export interface ProjectCollectionState { + activeProjectId: string; + projects: ProjectSummary[]; +} +``` + +`PersistedProjectDocument.id` should become the actual project ID. Existing project document fields such as clips, arrangement tracks, clip instances, sample metadata, tempo, mixer state, arrangement length, and loop range remain serializable project data. + +Imported sample blob storage should include project ownership. Acceptable first-pass strategies: + +- Use a composite blob key such as `${projectId}:${sampleId}`. +- Or add `projectId` to each imported sample blob record and query/delete by project ID if IndexedDB indexes are added. + +The implementation should choose the smaller safe migration path and document it in the PR. + +## UI Direction + +The project menu belongs in the transport bar near the current project name/save status area. + +The first project menu should include: + +- Current project name. +- List of local projects. +- `New Project`. +- `Rename Project`. +- `Delete Project`. + +Switching projects should close the menu, stop playback/preview, save or flush the current project state if practical, load the selected project, and reset selection to valid clip/instrument defaults. + +## Done when + +- Users can create, select, rename, and delete browser-local projects from the transport bar. +- Refreshing the browser restores the last active project. +- Each project restores its own clips, arrangement, mixer state, sample metadata, and imported sample blobs. +- Deleting a project requires confirmation and does not delete another project's sample blobs. +- Existing single-project IndexedDB data is preserved as an initial project after migration where practical. +- Autosave writes to the intended project after creating or switching projects. +- The transport bar displays the active project name from project state, not a hard-coded value. +- The PR documents any migration limitations. + +## Verification + +Run: + +- `npm run typecheck --if-present` +- `npm run lint --if-present` +- `npm run test --if-present` +- `npm run build --if-present` + +Manual check: + +- Start from an existing single-project browser profile if possible. +- Confirm the old project appears as a local project after migration. +- Create a second project and verify it starts blank. +- Rename a project and refresh the browser. +- Switch between projects and confirm clips, arrangement placements, mixer state, and tempo remain separate. +- Import a WAV file in one project, refresh, and verify the imported audio belongs only to that project. +- Delete a project and confirm another project still loads correctly. + +## PR notes + +- Summarize IndexedDB store/key changes. +- Explain active project ID persistence. +- Explain imported sample blob scoping and deletion behavior. +- Mention any unsupported project operations, such as duplication or file export/import. diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..b5b116a --- /dev/null +++ b/docs/product.md @@ -0,0 +1,66 @@ +# Product Definition + +## Product Goal + +Build a browser-first mini DAW for creating electronic music with short clips. The app should become a practical music-making tool, not just a demo, while keeping the first milestones narrow enough to implement and verify. + +## Target Users + +- Electronic music beginners who want a fast browser-based sketchpad. +- Musicians who want to create short loops without installing a desktop DAW. +- Developers and audio experimenters who want a small, understandable Web Audio codebase. + +## Core Workflow + +1. Open the browser app. +2. Create or select a browser-local project. +3. Create or select a 1-bar clip. +4. Build a clip with drum events and pitched notes, or import a local WAV file as an audio clip. +5. Start loop playback and edit while listening. +6. Place clips on the `SONG` arrangement timeline. +7. Play the arranged timeline to build a larger song. +8. Balance arrangement tracks with basic mixer controls. + +## MVP Definition + +The MVP is a browser-first 1-bar hybrid clip editor with: + +- 16-step drum sequencer. +- Basic piano roll. +- Loop playback. +- Bundled starter samples. +- Later WAV import for user audio clips. +- Basic project JSON export/import later. +- Serializable project state. + +## Non-goals + +- Full DAW replacement scope. +- Realtime audio recording. +- VST/plugin support. +- Cloud sync. +- Multiplayer collaboration. +- Advanced mastering tools. +- Desktop packaging before the browser app is useful. + +## Success Criteria + +- Users can create a short 1-bar loop with drums and pitched notes. +- Playback timing is stable enough for simple electronic music loops. +- Users can place clips on an arrangement timeline and hear the placed clips in song order. +- Users can adjust track and master levels and mute or solo tracks during arrangement playback. +- Users can keep separate songs or sketches as multiple local browser projects. +- Project data can be represented as JSON without runtime audio objects. +- The codebase separates UI rendering, project state, persistence, and audio scheduling. +- Future contributors can pick up feature specs and implement small, reviewable tasks. + +## Important Product Principles + +- Timing correctness matters. +- React UI must not own exact audio timing. +- Musical time is stored in ticks, not seconds. +- Project data must be serializable. +- Runtime audio objects such as `AudioBuffer` are not stored in project JSON. +- Use CSS Modules for component styles. +- Avoid unnecessary dependencies. +- Prefer small, focused changes. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..046e5af --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,128 @@ +# Testing Strategy + +## Required Checks + +Run these checks before opening a PR when the scripts exist: + +```bash +npm run typecheck +npm run lint +npm run test +npm run build +``` + +CI uses `--if-present` while the repository is still before the Vite scaffold. + +## Test Strategy + +- Pure utilities: unit tests. +- Tick/time conversion: unit tests. +- Data model transformations: unit tests. +- Clip length and arrangement length transformations: unit tests. +- Drum step subdivision tick math and event toggling: unit tests. +- Clip collection and sidebar membership transformations: unit tests. +- Arrangement clip instance creation, movement, deletion, and snapping: unit tests. +- Arrangement scheduler event expansion from clip instances: unit tests where practical. +- IndexedDB persistence adapters, migrations, and serialization boundaries: unit or integration tests with mocked storage where practical. +- Multi-project store operations: unit or integration tests for create, list, rename, delete, active project selection, and migration from the single active project shape. +- Project autosave/manual restore checks should verify that imported audio metadata and blobs remain separated. +- Variable hybrid clip length should cover 1, 2, and 4 bar tick lengths, editor grid derivation, shortening behavior, and arrangement default instance length. +- WAV encoder header, duration, and sample conversion helpers: unit tests. +- Pitched instrument metadata and sample-zone mapping: unit tests. +- Tempo control and scheduler tempo update behavior: unit tests where practical. +- Mixer decibel-to-gain conversion and mute/solo effective-gain logic: unit tests. +- Mixer state transformations for volume, mute, solo, and master volume: unit tests. +- Arrangement playback event expansion should preserve `trackId` so scheduled sources can route through the mixer. +- Sustain loop point calculations: unit tests. +- Sampler sustain metadata validation and fallback decisions: unit tests. +- Scheduler calculations: unit tests where possible. +- Imported WAV file-name, metadata, validation, and duration helpers: unit tests where practical. +- UI interactions: component tests later. +- Critical flows: browser end-to-end tests later, after the UI and workflows are stable enough to justify the framework. + +## Test Source Layout + +- `src/`: production code only. +- `tests/unit/`: unit tests for pure utilities, scheduler calculations, model transformations, and isolated module behavior. +- `tests/integration/`: integration tests for multi-module workflows when needed. + +Do not add an end-to-end test directory or framework yet. Add it only when a future feature spec needs browser flow coverage. + +Test files should use `*.test.ts` or `*.test.tsx`. Keep paths grouped by the production area they cover, for example: + +```text +tests/unit/audio/lookahead-scheduler.test.ts +tests/unit/audio/sampler-sustain.test.ts +tests/unit/utils/tick-time.test.ts +``` + +`tsconfig.test.json` owns TypeScript settings for tests. The root `tsconfig.json` should reference it so `npm run typecheck` checks test files as well as production code. + +## High-risk Areas + +- Tick-to-seconds conversion. +- Loop boundaries. +- Clip length boundary handling. +- Arrangement length boundary handling. +- Pause/resume tick offsets. +- Playhead wrapping at loop boundaries. +- BPM changes while stopped, paused, and playing. +- Pitched instrument selection. +- Drum step subdivision tick math. +- Drum step subdivision changes preserving existing events. +- Clip add/delete/rename selection fallback. +- Per-clip pitched instrument add/delete behavior. +- Removing pitched instruments that own note events. +- Arrangement clip placement snapping. +- Arrangement clip move/delete behavior. +- Arrangement playback event expansion across clip instance offsets. +- Arrangement playhead behavior during play, pause, resume, and stop. +- Sample start offsets, optional sustain loop points, and note release behavior. +- Sampler sustain fallback behavior when loop metadata is missing or invalid. +- Clip duplication. +- Sample import. +- Imported audio clip metadata and runtime-cache separation. +- Imported file persistence limitations across refresh. +- IndexedDB restore behavior for imported sample metadata and blobs. +- Multi-project active project migration and restore behavior. +- Autosave writing to the wrong project after a project switch. +- Imported sample blob collisions between projects. +- Project export/import. +- WAV export duration and missing-source failure behavior. +- Scheduler timing. +- Mixer decibel-to-gain conversion. +- Mixer mute/solo state interactions and effective audibility. +- Track-to-master routing during arrangement playback. +- Runtime level meter behavior and meter decay after stop. + +## Manual Testing Guidance for Audio Features + +Manual audio checks should verify: + +- Audio starts only after user interaction when required by the browser. +- One-shot samples play repeatedly without reusing the same source node. +- Loop playback does not double-trigger events at the loop boundary. +- UI playhead movement roughly matches audible playback. +- Pause preserves the runtime playhead position, resume continues from that position, and stop resets to the start. +- BPM changes while stopped affect the next playback start. +- BPM changes while paused affect resume from the paused tick. +- BPM changes while playing affect future scheduled drum and note events without using UI timers for exact playback. +- Starting, stopping, and restarting transport leaves no stuck sounds. +- Long sample-based piano notes behave as documented for the selected instrument. For the current Iowa Piano implementation, they should not retrigger or sound like repeated strikes. +- When sampler sustain metadata exists, long sample-based notes should sustain without obvious repeated attacks as much as the sample material allows. +- If sampler sustain metadata is missing or invalid, sample-based notes should fall back to one-shot playback rather than stuck or unstable sustain. +- Instrument switching changes piano roll playback sound without mutating existing note events. +- Tempo changes behave as documented for the current milestone. +- Mixer UI shell checks should verify fader, mute, solo, meter placeholder, and effect slot visuals without implying real audio routing. +- Functional mixer checks should verify track faders, master fader, mute, solo, and level meters affect real `SONG` playback. +- Drum subdivision settings of `1`, `2`, and `3` should toggle and play hits at the expected rhythmic positions. +- WAV import checks should verify valid WAV import, invalid file rejection, imported clip selection, displayed duration metadata, and clear behavior after refresh when imported file persistence is not implemented. +- Arrangement placement checks should verify dragging clips into tracks, moving placed clips, deleting placed clips, and playback from `SONG` mode. +- Imported audio clip arrangement checks should verify clear missing-source behavior after refresh until imported file persistence exists. +- Multi-project checks should verify creating, renaming, switching, deleting, refreshing, and imported audio isolation across projects. + +Use headphones or speakers at a safe volume. Record browser, OS, and device details when reporting audio timing issues. + +## Test Integrity + +Do not remove tests or checks just to make a task pass. If a test is obsolete, update it with the code change and explain why in the PR. diff --git a/docs/ui-design.md b/docs/ui-design.md new file mode 100644 index 0000000..4c32502 --- /dev/null +++ b/docs/ui-design.md @@ -0,0 +1,389 @@ +# UI and Design + +## CSS Modules Convention + +Use CSS Modules for component-level styles: + +```text +ComponentName.tsx +ComponentName.module.css +``` + +Class names should describe the component part or state, such as `root`, `lane`, `stepButton`, `active`, or `noteBlock`. + +## Global Styles Policy + +Keep global CSS minimal: + +- CSS reset or normalization. +- `html`, `body`, and `#root` sizing. +- Base font rendering. +- Design tokens as CSS custom properties. + +Do not put feature-specific component styles in global CSS. + +## Design Token Policy + +Use CSS variables for shared values: + +- Colors. +- Spacing. +- Border radius. +- Typography. +- Z-index layers. +- Timing constants for transitions. + +Tokens should live in `src/styles/` after the scaffold exists. + +Use a two-layer token model: + +- Primitive tokens define raw palette, spacing, typography, radius, and timing values. +- Semantic tokens define product meaning by referencing primitive tokens. + +Example: + +```css +:root { + --color-gray-950: #0f172a; + --color-blue-500: #3b82f6; + --space-2: 8px; + --radius-2: 6px; + + --color-app-background: var(--color-gray-950); + --color-control-accent: var(--color-blue-500); + --space-control-gap: var(--space-2); + --radius-control: var(--radius-2); +} +``` + +Component CSS Modules should use semantic tokens: + +```css +.button { + background: var(--color-control-accent); + border-radius: var(--radius-control); +} +``` + +Do not reference primitive tokens directly in component styles unless there is a documented exception. This keeps component styling tied to product meaning instead of raw implementation values. + +## Initial Layout + +The first usable screen should focus on the clip editor: + +- Transport bar. +- Selected clip editor. +- Drum step sequencer. +- Piano roll. + +Do not default to a marketing landing page. + +## Later Layout + +Later milestones can add: + +- Clip library. +- Arrangement view. +- Selected clip editor panel. + +The arrangement should support horizontal time and vertical track lanes when that milestone arrives. + +## Arrangement View + +The `PAT` / `SONG` toggle should switch the main workspace mode: + +- `PAT`: show the current selected hybrid clip editor with the drum step sequencer and piano roll. +- `SONG`: show an arrangement view UI shell. + +The existing top transport bar and left project sidebar should stay visible in both modes. + +The first arrangement view should be a UI shell, not a full arrangement editor. It should include: + +- Arrangement toolbar. +- Snap display. +- Timeline ruler with bar numbers. +- Fixed left track header column. +- Scrollable timeline grid. +- Horizontal track lanes. +- Demo clip blocks. +- Bottom scrollbar or scrollbar-like visual area. +- Zoom placeholder. + +Track headers, timeline lanes, and clip blocks must align exactly. Use shared geometry constants for row height, ruler height, bar width, beat subdivision width, and timeline content width. Inline styles are acceptable for dynamic arrangement geometry such as clip positions, clip widths, grid widths, and playhead positions. + +Do not introduce real arrangement data, persisted clip instances, arrangement playback, or drag-and-drop editing in the first arrangement UI shell unless a feature spec explicitly expands the scope. + +The arrangement placement feature expands this shell into an editable first pass. Static demo clip blocks should be removed once real `ClipInstance` state is available. + +The arrangement toolbar includes length controls for adding or removing bars. These controls update serializable arrangement state and the ruler/grid derive from that state instead of hard-coded 16-bar assumptions. + +## Arrangement Clip Placement + +The next arrangement step should make the `SONG` view editable and playable. + +- Sidebar clips act as drag sources. +- Arrangement track lanes act as drop targets. +- Dropping a clip creates a visible clip block backed by a serializable `ClipInstance`. +- Moving a clip block updates `startTick` and `trackId`, not stored pixel positions. +- Selecting a clip block should show a clear selected state. +- Right-clicking a clip block deletes only that arrangement instance, not the source clip in the sidebar. +- Static demo clip blocks should be removed or replaced with seeded serializable state. +- The arrangement ruler may expose draggable loop start and loop end handles snapped to bar boundaries. +- The timeline should show loop start/end boundary lines. Avoid filling the full loop range across tracks; a small ruler connector between the loop handles is enough. + +Use shared arrangement geometry constants for row height, ruler height, bar width, beat width, timeline width, and track header width. Inline styles are acceptable for dynamic clip geometry such as `left`, `top`, `width`, and transform values. + +Start with a simple snap policy, such as beat-level snapping at 480 ticks, unless a feature spec introduces user-selectable snap values. + +`SONG` mode playback should render a visual arrangement playhead. The playhead may use `requestAnimationFrame` for display, but exact playback must remain scheduled by the audio engine against `AudioContext.currentTime`. + +## Clip Length Controls + +Hybrid clip editors expose a compact length control for 1, 2, and 4 bars. + +- The selected length should update clip `lengthTicks`. +- Drum and piano roll grids should derive from the selected clip length. +- Shortening a clip should avoid silent data loss when events would fall outside the new length. +- The control belongs in the clip editor header or nearby toolbar, not in the audio engine. +- Longer piano roll grids may scroll horizontally; the 1-bar view should remain compact. + +## Export UI + +Arrangement export should be presented as an explicit command, likely near the existing Export entry or arrangement toolbar. + +- Show a busy state while rendering. +- Show a clear error if required imported sample data is missing. +- Download a WAV file when export completes. +- Do not imply MP3 or cloud export support until those features exist. + +## Project Menu + +Multi-project management should live in the transport bar, near the active project name and save status. + +The first project menu should: + +- Display the active project name from project state, not a hard-coded label. +- Open a compact menu or popover from the project-name area. +- List browser-local projects. +- Provide `New Project`, `Rename Project`, and `Delete Project` actions. +- Make project switching explicit and stop playback or preview before replacing app state. +- Show clear confirmation before deleting a project. + +Do not introduce a full dashboard page for the first multi-project feature. Keep the menu compact enough to fit the editor-focused workflow. + +Use CSS Modules and semantic design tokens. Inline styles are not expected for this menu because geometry is not tick-derived editor layout. + +## Arrangement Mixer Panel + +The mixer should start as a bottom dock inside `SONG` mode, below the arrangement timeline. + +The first mixer panel should be a UI shell: + +- Track channel strips corresponding to visible arrangement tracks. +- A master channel strip when practical. +- Volume faders. +- Level meter placeholders. +- Mute and solo toggle buttons. +- Basic effect slot placeholders. +- Horizontal scrolling inside the mixer panel when channel strips exceed available width. + +This panel belongs in the arrangement workspace, not the focused `PAT` clip editor. `PAT` mode may later expose simple preview controls, but full mixer controls are song/track-level UI. + +The first mixer panel should not imply real audio routing. Use local visual state or mock values for faders, mute/solo, meters, and effect slots until a dedicated mixer routing feature connects those controls to the audio engine. + +Use CSS Modules and semantic design tokens. Keep primitive token references out of component CSS unless there is a documented exception. + +## Mixer Audio Controls + +After arrangement playback can schedule sources by `trackId`, the mixer panel should become functional: + +- Track faders control real track gain. +- The master fader controls real output gain. +- `M` buttons mute real track output. +- `S` buttons solo tracks using the documented mute/solo rule. +- Track meters display runtime signal level for each track. +- The master meter displays runtime output level. + +The UI should dispatch mixer setting changes to state/model or audio-engine orchestration. React components must not own Web Audio nodes directly. + +Meters are visual feedback only. They may update via `requestAnimationFrame`, but meter timing must not affect audio scheduling. + +When playback is stopped, meters may settle to zero while faders and mute/solo settings remain editable. + +Effect slots should remain visibly disabled or placeholder-only until a dedicated effects feature implements real processing. + +The functional mixer should label its meters as live/runtime feedback and keep effect slots disabled. The master strip exposes master volume and meter feedback; track strips expose volume, mute, solo, and meter feedback. + +## Component Naming Recommendations + +Prefer names that match the product domain: + +- `TransportBar` +- `ClipEditor` +- `DrumStepSequencer` +- `DrumLane` +- `StepButton` +- `PianoRoll` +- `NoteBlock` +- `ArrangementView` +- `MixerPanel` +- `ChannelStrip` + +Feature-specific components should live under `src/features/` unless they are truly reusable. + +## Dynamic Editor Geometry Policy + +Inline styles are acceptable for computed editor geometry, including: + +- Note positions. +- Note widths. +- Grid coordinates. +- Playhead transform values. + +Keep static appearance in CSS Modules. Keep dynamic numeric layout derived from ticks, pitch, and grid dimensions. + +## Playhead Display + +Clip editors may render a vertical playhead to show the current runtime transport position. + +- The playhead position should be derived from tick position and editor dimensions. +- Use `requestAnimationFrame` for visual updates when playback is active. +- Keep the playhead as visual feedback only; it must not drive exact audio scheduling. +- Use CSS Modules and semantic design tokens for static playhead styling. +- Inline styles are acceptable for computed playhead geometry such as `left` or `transform`. +- When stopped, the playhead should reset to the start of the selected clip. When paused, it should remain at the paused tick. + +## Piano Roll Editing + +The initial piano roll should use a compact C4-C5 pitch range that matches the bundled Iowa Piano sample files. + +- Render 13 pitch rows from C5 down to C4. +- Render 32 columns across the 1-bar clip. +- Use left-click to create a short note. +- Use left-click drag on empty grid space to create a longer note. +- Use left-click drag on an existing note to move its pitch and start tick. +- Use right-click on an existing note to delete it. +- Store the result in serializable `noteEvents`; do not store UI geometry as project data. +- Use inline styles only for computed note geometry such as top, left, width, and height. + +## Pitched Instrument Selection + +Initial options: + +- `Default Synth`: oscillator-based playback. +- `Iowa Piano`: sample-based playback using bundled Iowa Piano WAV files. + +Pitched instruments should appear as selectable clip child items in the left project sidebar, alongside the drum lane entry. Selecting a pitched instrument changes which instrument's note events are visible and editable in the piano roll. + +Changing the selected pitched instrument should not mutate existing `NoteEvent` timing or pitch data. Note events belong to a specific pitched instrument by serializable `instrumentId`, so `Default Synth` and `Iowa Piano` notes may coexist in the same clip and can play at the same time. + +The current implementation keeps the selected sidebar item as runtime app state. The note events themselves store serializable instrument IDs. Once sidebar clip and instrument management is implemented, each clip should also store a serializable list of pitched instrument IDs that controls which pitched instrument child items appear under that clip. + +## Sidebar Clip and Instrument Management + +The left project sidebar should become the primary place to manage reusable M1 hybrid clips. + +- The project or Clips section should expose a `+` button for creating a new clip. +- The clip add button may open a compact menu with `Build a clip` and `Import a file`. +- `Build a clip` should preserve the existing new hybrid clip behavior. +- `Import a file` should open a browser file picker for WAV import and create an audio clip entry. +- Clip rows should expose a `+` button for adding a pitched instrument and a `-` button for deleting the clip. +- Pitched instrument rows should expose a `-` button for removing that instrument from the clip. +- `Drums` remains a mandatory child item for every hybrid clip and should not expose a remove action in the first version. +- Available pitched instruments should be shown in a compact picker or popover when adding an instrument. +- The instrument picker should not be clipped by sidebar, panel, or workspace overflow. +- Icon-only controls must be semantic buttons with accessible labels. +- Selection state should remain visually clear for clip rows, `Drums`, and pitched instrument rows. +- Expanded or collapsed clip groups should use `aria-expanded` when practical. + +Deleting a clip or pitched instrument should avoid surprising data loss. The first implementation should keep at least one clip available and should require confirmation or a documented safe fallback before deleting notes owned by a removed pitched instrument. + +## Imported Audio Clips + +Imported WAV clips should appear in the same sidebar clip list as built hybrid clips, but selecting one should not show the drum sequencer and piano roll. + +The first audio clip selected view may be a simple placeholder or detail panel: + +- Audio clip name. +- Source file name. +- Duration. +- Import status or limitation if the file is session-only. +- Optional preview/play control if supported by the current audio engine. + +Use clear copy for limitations. If imported files are not persisted yet, the UI should not imply that they survive refresh or project export. + +When IndexedDB persistence is available, imported audio clip details may indicate that the clip is stored locally. The top transport/status area should show minimal project save status such as loading, saving, saved, or save failed. + +Future arrangement duration resizing should happen in the arrangement view, not during import. The UI should treat that as non-destructive trimming or clip-instance length editing unless a later time-stretching feature explicitly adds stretch behavior. + +## Sample Display Names + +When displaying bundled drum sample names in the UI, derive the label from the `.wav` file name: + +- Remove the `.wav` extension. +- Replace underscores (`_`) with spaces. +- Convert the result to uppercase. + +Example: `Fred_Kick_1.wav` displays as `FRED KICK 1`. + +## Drum Lane Editing + +Drum lane names can act as sample selectors. Clicking a lane name may open a compact scrollable list of bundled drum samples. Render this menu as a popover above clipping editor containers when needed so it is not cut off by panel or workspace overflow. Selecting a sample should update the lane's visible label, the lane's `sampleId`, and existing drum events for that lane in serializable clip state. + +Drum lanes may be reordered vertically with drag and drop. The visual order should come from the selected clip's ordered `drumLanes` array rather than a hard-coded component order. + +## Drum Step Subdivisions + +The drum sequencer may support smaller hit targets while preserving the primary `1` through `16` step labels. + +- Keep the primary step number row stable. +- Render substep buttons below each primary number. +- Subdivision `1` renders one button per primary step. +- Subdivision `2` renders two smaller buttons per primary step. +- Subdivision `3` renders three smaller buttons per primary step. +- Keep beat grouping visible every four primary steps. +- Put the subdivision control near the step sequencer header with compact choices such as `1x`, `2x`, and `3x`. +- Use semantic buttons with `aria-pressed` for subdivision choices. +- Use accessible labels for substep buttons, including lane name, primary step number, and substep number. +- Prefer internal panel scrolling over document-level scrolling if the subdivided grid becomes too wide. +- Preserve readable lane names; do not shrink the lane label column so much that sample names become unreadable. + +Substep button styling should continue to use CSS Modules and semantic tokens. Dynamic grid sizing may use inline styles when computed from subdivision count. + +## UI Reference Policy + +If a design prototype uses Tailwind, inline styles, or CDN assets, treat it as visual reference only. Convert the design into semantic React components, CSS Modules, and shared CSS variables. + +- Do not add Tailwind, Tailwind config, or Tailwind CDN scripts unless a future architecture decision explicitly changes the styling strategy. +- Do not copy utility-class-heavy HTML directly into React components. +- Preserve the primitive/semantic token model in `src/styles/tokens.css`. +- Component styles should live in matching `.module.css` files. +- Component CSS Modules should reference semantic tokens, not primitive tokens, unless there is a documented exception. +- Inline styles remain acceptable for dynamic editor geometry such as note positions, widths, grid coordinates, and velocity heights. + +For the main DAW UI shell, the Tailwind prototype should inform visual direction only: dark DAW workspace, compact editor spacing, muted lime active states, muted teal MIDI notes, thin borders, and clear panel separation. + +For arrangement view prototypes from tools such as Google Stitch, preserve visual intent but correct layout problems during implementation. Do not copy Tailwind utility markup into React. Rebuild the view as semantic components with CSS Modules and shared tokens. + +## Main UI Shell Styling + +The initial main DAW UI shell converts the Tailwind-based prototype into React components with CSS Modules. + +- The project remains CSS Modules-based. +- Design tokens live in `src/styles/tokens.css`. +- Primitive tokens define raw values and semantic tokens reference those primitives. +- Component styles live in matching `.module.css` files and should use semantic tokens. +- Dynamic editor geometry may use inline styles for note positions, note widths, note top values, grid coordinates, and velocity heights. +- Tailwind should not be added unless a future architecture decision explicitly changes the styling strategy. + +## Accessibility Basics + +- Use semantic buttons for step toggles. +- Provide keyboard access where practical. +- Keep visible focus states. +- Use labels or accessible names for icon-only controls. +- Avoid color as the only indicator of state. + +## Dependencies + +Do not require a visual design system dependency at this stage. Add UI dependencies only when they solve a clear implementation problem and the task justifies the cost. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c1b8c79 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,36 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist", "coverage"], + }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + globals: { + AudioContext: "readonly", + AudioBuffer: "readonly", + fetch: "readonly", + document: "readonly", + HTMLElement: "readonly", + window: "readonly", + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..257629a --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + Mini Web DAW + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2160554 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3093 @@ +{ + "name": "mini-web-daw-jaewan-park", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mini-web-daw-jaewan-park", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "eslint": "^10.4.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", + "vitest": "^4.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a07c96 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "mini-web-daw-jaewan-park", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "eslint": "^10.4.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", + "vitest": "^4.1.7" + } +} diff --git a/public/samples/drums/Fred_Clap_1.wav b/public/samples/drums/Fred_Clap_1.wav new file mode 100644 index 0000000..64bfd51 Binary files /dev/null and b/public/samples/drums/Fred_Clap_1.wav differ diff --git a/public/samples/drums/Fred_Clap_2.wav b/public/samples/drums/Fred_Clap_2.wav new file mode 100644 index 0000000..5c50173 Binary files /dev/null and b/public/samples/drums/Fred_Clap_2.wav differ diff --git a/public/samples/drums/Fred_Clap_3.wav b/public/samples/drums/Fred_Clap_3.wav new file mode 100644 index 0000000..c7749c8 Binary files /dev/null and b/public/samples/drums/Fred_Clap_3.wav differ diff --git a/public/samples/drums/Fred_Closed_Hi-Hat.wav b/public/samples/drums/Fred_Closed_Hi-Hat.wav new file mode 100644 index 0000000..b23791e Binary files /dev/null and b/public/samples/drums/Fred_Closed_Hi-Hat.wav differ diff --git a/public/samples/drums/Fred_Kick_1.wav b/public/samples/drums/Fred_Kick_1.wav new file mode 100644 index 0000000..1ce563a Binary files /dev/null and b/public/samples/drums/Fred_Kick_1.wav differ diff --git a/public/samples/drums/Fred_Kick_2.wav b/public/samples/drums/Fred_Kick_2.wav new file mode 100644 index 0000000..c38d967 Binary files /dev/null and b/public/samples/drums/Fred_Kick_2.wav differ diff --git a/public/samples/drums/Fred_Kick_3.wav b/public/samples/drums/Fred_Kick_3.wav new file mode 100644 index 0000000..b46e18a Binary files /dev/null and b/public/samples/drums/Fred_Kick_3.wav differ diff --git a/public/samples/drums/Fred_Open_Hi-Hat.wav b/public/samples/drums/Fred_Open_Hi-Hat.wav new file mode 100644 index 0000000..c8339ca Binary files /dev/null and b/public/samples/drums/Fred_Open_Hi-Hat.wav differ diff --git a/public/samples/drums/Fred_Snare_1.wav b/public/samples/drums/Fred_Snare_1.wav new file mode 100644 index 0000000..a15dd71 Binary files /dev/null and b/public/samples/drums/Fred_Snare_1.wav differ diff --git a/public/samples/drums/Fred_Snare_2.wav b/public/samples/drums/Fred_Snare_2.wav new file mode 100644 index 0000000..590b954 Binary files /dev/null and b/public/samples/drums/Fred_Snare_2.wav differ diff --git a/public/samples/drums/Fred_Snare_3.wav b/public/samples/drums/Fred_Snare_3.wav new file mode 100644 index 0000000..3c19967 Binary files /dev/null and b/public/samples/drums/Fred_Snare_3.wav differ diff --git a/public/samples/drums/Signature_Sounds_CC0_License_Declaration.pdf b/public/samples/drums/Signature_Sounds_CC0_License_Declaration.pdf new file mode 100644 index 0000000..810d746 Binary files /dev/null and b/public/samples/drums/Signature_Sounds_CC0_License_Declaration.pdf differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/A4.wav b/public/samples/pitched_instruments/Iowa_Piano/A4.wav new file mode 100644 index 0000000..566cf81 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/A4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/Ab4.wav b/public/samples/pitched_instruments/Iowa_Piano/Ab4.wav new file mode 100644 index 0000000..9d33cce Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/Ab4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/B4.wav b/public/samples/pitched_instruments/Iowa_Piano/B4.wav new file mode 100644 index 0000000..cd1f72f Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/B4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/Bb4.wav b/public/samples/pitched_instruments/Iowa_Piano/Bb4.wav new file mode 100644 index 0000000..a793c21 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/Bb4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/C4.wav b/public/samples/pitched_instruments/Iowa_Piano/C4.wav new file mode 100644 index 0000000..21bfeec Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/C4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/C5.wav b/public/samples/pitched_instruments/Iowa_Piano/C5.wav new file mode 100644 index 0000000..7ec0c2c Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/C5.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/D4.wav b/public/samples/pitched_instruments/Iowa_Piano/D4.wav new file mode 100644 index 0000000..00bcd99 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/D4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/Db4.wav b/public/samples/pitched_instruments/Iowa_Piano/Db4.wav new file mode 100644 index 0000000..a76b331 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/Db4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/E4.wav b/public/samples/pitched_instruments/Iowa_Piano/E4.wav new file mode 100644 index 0000000..fd00380 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/E4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/Eb4.wav b/public/samples/pitched_instruments/Iowa_Piano/Eb4.wav new file mode 100644 index 0000000..b4a15f6 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/Eb4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/F4.wav b/public/samples/pitched_instruments/Iowa_Piano/F4.wav new file mode 100644 index 0000000..efd2ae7 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/F4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/G4.wav b/public/samples/pitched_instruments/Iowa_Piano/G4.wav new file mode 100644 index 0000000..b93aea5 Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/G4.wav differ diff --git a/public/samples/pitched_instruments/Iowa_Piano/Gb4.wav b/public/samples/pitched_instruments/Iowa_Piano/Gb4.wav new file mode 100644 index 0000000..cdaba4a Binary files /dev/null and b/public/samples/pitched_instruments/Iowa_Piano/Gb4.wav differ diff --git a/src/app/App.module.css b/src/app/App.module.css new file mode 100644 index 0000000..1fb3c58 --- /dev/null +++ b/src/app/App.module.css @@ -0,0 +1,138 @@ +.appShell { + background: var(--color-background); + color: var(--color-text); + display: grid; + grid-template-rows: var(--top-bar-height) minmax(0, 1fr); + height: 100%; + min-width: 0; + overflow: hidden; +} + +.mainLayout { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} + +.workspace { + background: var(--color-surface); + display: grid; + gap: var(--space-panel); + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + overflow: hidden; + padding: var(--space-panel); +} + +.workspaceSong { + grid-template-rows: minmax(0, 1fr); +} + +.workspaceHeader { + align-items: center; + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + display: flex; + justify-content: space-between; + min-height: 56px; + padding: var(--space-gutter) var(--space-panel); +} + +.eyebrow { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + margin: 0 0 var(--space-unit); + text-transform: uppercase; +} + +.title { + color: var(--color-text); + font-size: var(--font-size-title); + line-height: var(--line-height-tight); + margin: 0; +} + +.clipMeta { + align-items: center; + color: var(--color-text-muted); + display: flex; + flex-wrap: wrap; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + gap: var(--space-gutter); + justify-content: flex-end; + text-transform: uppercase; +} + +.clipMeta span { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + padding: var(--space-unit) var(--space-gutter); +} + +.lengthControl { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + display: flex; + overflow: hidden; +} + +.lengthButton { + background: transparent; + border: 0; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + min-height: 24px; + padding: 0 var(--space-gutter); + text-transform: uppercase; +} + +.lengthButton:hover, +.lengthButton:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.lengthButtonActive { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + +.clipMeta .errorMeta { + border-color: var(--color-status-error); + color: var(--color-status-error); + text-transform: none; +} + +.editorStack { + display: grid; + gap: var(--space-panel); + grid-template-rows: minmax(180px, auto) minmax(360px, 1fr); + min-height: 0; + overflow: hidden; +} + +.singlePanel { + min-height: 0; + overflow: hidden; +} + +@media (max-width: 920px) { + .mainLayout { + grid-template-columns: minmax(212px, 32%) minmax(0, 1fr); + } + + .workspaceHeader { + align-items: flex-start; + flex-direction: column; + gap: var(--space-gutter); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..03113bc --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,2330 @@ +import { useEffect, useRef, useState } from "react"; + +import { + BUNDLED_DRUM_SAMPLES, + createAudioEngine, + expandClipInstancesForPlayback, + renderArrangementToWav, + type BundledSampleMeta, + type MixerLevelSnapshot, + type NoteLoopEvent, + type SampleLoopEvent, +} from "../audio"; +import { + AudioClipDetails, + ArrangementView, + DrumSequencer, + PianoRoll, + ProjectSidebar, + TransportBar, + type InstrumentId, + type TransportMode, + type TransportState, +} from "../features"; +import { + DEFAULT_PITCHED_INSTRUMENT_ID, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + HYBRID_CLIP_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, + addPitchedInstrumentToClip, + addNoteEvent, + createClipInstance, + createDefaultArrangementLoopRange, + createDefaultArrangementTracks, + createDefaultMasterMixerState, + createDefaultTrackMixerStates, + createImportedAudioClipDraft, + createImportedAudioIds, + createEmptyHybridClip, + deleteClipInstance, + deleteNoteEvent, + getClipDeleteConfirmationMessage, + getHybridClipBarCount, + getHybridClipLengthTicks, + getClipInstancesOutsideArrangementLength, + getPitchedInstrument, + hasHybridClipEventsOutsideLength, + hasNoteEventsForPitchedInstrument, + isAudioClip, + isHybridClip, + moveDrumLane, + moveClipInstance, + moveNoteEvent, + normalizeArrangementLengthBars, + normalizeArrangementLoopRange, + removeClipInstancesOutsideArrangementLength, + removePitchedInstrumentFromClip, + renameClip, + toggleDrumSubstep, + validateImportedWavFile, + updateMasterMixerState, + updateDrumLaneSample, + updateDrumStepSubdivision, + updateHybridClipLength, + updateTrackMixerState, + type ArrangementLoopRange, + type ArrangementTrack, + type AudioClip, + type Clip, + type ClipInstance, + type DrumEvent, + type DrumLaneId, + type DrumStepSubdivision, + type HybridClip, + type HybridClipLengthBars, + type MasterMixerState, + type NoteEvent, + type PitchedInstrumentId, + type SampleMeta, + type TrackMixerState, +} from "../model"; +import { + createIndexedDbProjectStore, + createProjectId, + createPersistedProjectDocument, + getImportedSampleIds, + type PersistedProjectDocument, + type ProjectSummary, +} from "../persistence"; +import { DEFAULT_TEMPO_BPM, clampTempoBpm, type Tick } from "../utils"; +import styles from "./App.module.css"; + +const audioEngine = createAudioEngine(); +const projectStore = createIndexedDbProjectStore(); +const DEFAULT_CLIP_ID = "clip-1"; +const DEFAULT_PROJECT_NAME = "Project 1"; +const AUTOSAVE_DEBOUNCE_MS = 600; + +type PersistenceStatus = "error" | "loading" | "saved" | "saving"; + +function drumEventsToSampleLoopEvents( + drumEvents: readonly DrumEvent[], +): SampleLoopEvent[] { + return drumEvents.map((event) => ({ + gain: event.velocity, + id: event.id, + sampleId: event.sampleId, + startTick: event.startTick, + })); +} + +function noteEventsToNoteLoopEvents( + noteEvents: readonly NoteEvent[], +): NoteLoopEvent[] { + return noteEvents.map((event) => ({ + durationTicks: event.durationTicks, + gain: event.velocity, + id: event.id, + instrumentId: event.instrumentId, + midiNote: event.midiNote, + startTick: event.startTick, + })); +} + +function createEmptyMixerLevels( + tracks: readonly ArrangementTrack[], +): MixerLevelSnapshot { + return { + masterLevel: 0, + trackLevels: Object.fromEntries(tracks.map((track) => [track.id, 0])), + }; +} + +function createNextHybridClip(clips: readonly Clip[]): HybridClip { + const nextClipNumber = + clips.reduce((highestClipNumber, clip) => { + const match = /^clip-(\d+)$/.exec(clip.id); + const clipNumber = match ? Number.parseInt(match[1] ?? "", 10) : 0; + + return Math.max(highestClipNumber, Number.isNaN(clipNumber) ? 0 : clipNumber); + }, 0) + 1; + + return createEmptyHybridClip({ + id: `clip-${nextClipNumber}`, + name: `Clip ${nextClipNumber}`, + }); +} + +function createNextProjectName(projectSummaries: readonly ProjectSummary[]): string { + const nextProjectNumber = + projectSummaries.reduce((highestProjectNumber, projectSummary) => { + const match = /^Project (\d+)$/u.exec(projectSummary.name); + const projectNumber = match ? Number.parseInt(match[1] ?? "", 10) : 0; + + return Math.max( + highestProjectNumber, + Number.isNaN(projectNumber) ? 0 : projectNumber, + ); + }, 0) + 1; + + return `Project ${nextProjectNumber}`; +} + +function createBlankProjectDocument({ + existingProjectIds, + name, + now = Date.now(), +}: { + existingProjectIds: readonly string[]; + name: string; + now?: number; +}): PersistedProjectDocument { + const arrangementTracks = createDefaultArrangementTracks(); + + return createPersistedProjectDocument({ + arrangementLengthBars: DEFAULT_ARRANGEMENT_LENGTH_BARS, + arrangementLoopRange: createDefaultArrangementLoopRange( + DEFAULT_ARRANGEMENT_LENGTH_BARS, + ), + arrangementTracks, + clipInstances: [], + clips: [createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" })], + createdAt: now, + id: createProjectId(existingProjectIds), + masterMixerState: createDefaultMasterMixerState(), + name, + sampleMetas: [], + savedAt: now, + tempoBpm: DEFAULT_TEMPO_BPM, + trackMixerStates: createDefaultTrackMixerStates(arrangementTracks), + }); +} + +function createArrangementExportFileName(projectName: string): string { + const safeProjectName = + projectName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-|-$/gu, "") || "mini-daw"; + const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); + + return `${safeProjectName}-arrangement-${timestamp}.wav`; +} + +function createRuntimeImportedSampleKey(projectId: string, sampleId: string): string { + return `${projectId}::${sampleId}`; +} + +function downloadBlob(blob: Blob, fileName: string): void { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = fileName; + link.style.display = "none"; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + +function getPersistenceStatusLabel(status: PersistenceStatus): string { + if (status === "loading") { + return "Loading project"; + } + + if (status === "saving") { + return "Saving"; + } + + if (status === "error") { + return "Save failed"; + } + + return "Saved"; +} + +export function App() { + const [transportState, setTransportState] = useState("stopped"); + const [transportMode, setTransportMode] = useState("pattern"); + const [bpm, setBpm] = useState(DEFAULT_TEMPO_BPM); + const bpmRef = useRef(DEFAULT_TEMPO_BPM); + const [clips, setClips] = useState(() => [ + createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" }), + ]); + const clipsRef = useRef(clips); + const [arrangementTracks, setArrangementTracks] = useState(() => + createDefaultArrangementTracks(), + ); + const [arrangementLengthBars, setArrangementLengthBars] = useState( + DEFAULT_ARRANGEMENT_LENGTH_BARS, + ); + const arrangementLengthBarsRef = useRef(arrangementLengthBars); + const [trackMixerStates, setTrackMixerStates] = useState( + () => createDefaultTrackMixerStates(arrangementTracks), + ); + const trackMixerStatesRef = useRef(trackMixerStates); + const [masterMixerState, setMasterMixerState] = useState(() => + createDefaultMasterMixerState(), + ); + const masterMixerStateRef = useRef(masterMixerState); + const [mixerLevels, setMixerLevels] = useState(() => + createEmptyMixerLevels(arrangementTracks), + ); + const [arrangementLoopRange, setArrangementLoopRange] = + useState(() => + createDefaultArrangementLoopRange(arrangementLengthBars), + ); + const arrangementLoopRangeRef = + useRef(arrangementLoopRange); + const [clipInstances, setClipInstances] = useState([]); + const clipInstancesRef = useRef(clipInstances); + const [selectedClipInstanceId, setSelectedClipInstanceId] = useState< + string | null + >(null); + const [sampleMetas, setSampleMetas] = useState([]); + const sampleMetasRef = useRef(sampleMetas); + const [isClipImporting, setIsClipImporting] = useState(false); + const [clipImportError, setClipImportError] = useState(null); + const [isArrangementExporting, setIsArrangementExporting] = useState(false); + const [arrangementExportError, setArrangementExportError] = useState< + string | null + >(null); + const [isPersistenceReady, setIsPersistenceReady] = useState(false); + const [persistenceStatus, setPersistenceStatus] = + useState("loading"); + const [persistenceError, setPersistenceError] = useState(null); + const [isProjectOperationPending, setIsProjectOperationPending] = + useState(false); + const durablePersistenceErrorRef = useRef(null); + const [projectSummaries, setProjectSummaries] = useState([]); + const projectSummariesRef = useRef(projectSummaries); + const [activeProjectId, setActiveProjectId] = useState(""); + const activeProjectIdRef = useRef(activeProjectId); + const [activeProjectCreatedAt, setActiveProjectCreatedAt] = useState(Date.now()); + const activeProjectCreatedAtRef = useRef(activeProjectCreatedAt); + const [projectName, setProjectName] = useState(DEFAULT_PROJECT_NAME); + const projectNameRef = useRef(projectName); + const importedSampleBlobsRef = useRef>(new Map()); + const runtimeImportedSampleKeysRef = useRef>(new Set()); + const [selectedClipId, setSelectedClipId] = useState(DEFAULT_CLIP_ID); + const [selectedInstrumentId, setSelectedInstrumentId] = + useState("drums"); + const [selectedPitchedInstrumentId, setSelectedPitchedInstrumentId] = + useState(DEFAULT_PITCHED_INSTRUMENT_ID); + const selectedClip = + clips.find((clip) => clip.id === selectedClipId) ?? clips[0]!; + const selectedClipRef = useRef(selectedClip); + const selectedHybridClip = isHybridClip(selectedClip) ? selectedClip : null; + const selectedAudioClip = isAudioClip(selectedClip) ? selectedClip : null; + const selectedSampleMeta = selectedAudioClip + ? sampleMetas.find((sampleMeta) => sampleMeta.id === selectedAudioClip.sampleId) + : undefined; + const [playheadTick, setPlayheadTick] = useState(0); + const playheadTickRef = useRef(0); + const [audioError, setAudioError] = useState(null); + const [isAudioClipPreviewPlaying, setIsAudioClipPreviewPlaying] = + useState(false); + const shouldShowPlayhead = transportState !== "stopped"; + const hasSelectedPitchedInstrument = + selectedHybridClip?.pitchedInstrumentIds.includes(selectedPitchedInstrumentId) ?? + false; + const selectedPitchedInstrumentName = hasSelectedPitchedInstrument + ? getPitchedInstrument(selectedPitchedInstrumentId).name + : "-"; + const selectedPitchedNoteEvents = hasSelectedPitchedInstrument + ? selectedHybridClip?.noteEvents.filter( + (event) => event.instrumentId === selectedPitchedInstrumentId, + ) ?? [] + : []; + + useEffect(() => { + clipsRef.current = clips; + selectedClipRef.current = selectedClip; + }, [clips, selectedClip]); + + useEffect(() => { + clipInstancesRef.current = clipInstances; + }, [clipInstances]); + + useEffect(() => { + arrangementLengthBarsRef.current = arrangementLengthBars; + }, [arrangementLengthBars]); + + useEffect(() => { + arrangementLoopRangeRef.current = arrangementLoopRange; + }, [arrangementLoopRange]); + + useEffect(() => { + sampleMetasRef.current = sampleMetas; + }, [sampleMetas]); + + useEffect(() => { + projectSummariesRef.current = projectSummaries; + }, [projectSummaries]); + + useEffect(() => { + activeProjectIdRef.current = activeProjectId; + }, [activeProjectId]); + + useEffect(() => { + activeProjectCreatedAtRef.current = activeProjectCreatedAt; + }, [activeProjectCreatedAt]); + + useEffect(() => { + projectNameRef.current = projectName; + }, [projectName]); + + useEffect(() => { + let isCancelled = false; + + async function restoreProject() { + let shouldEnableAutosave = true; + + try { + let collection = await projectStore.loadProjectCollection(); + let persistedProject = collection.activeProjectId + ? await projectStore.loadProject(collection.activeProjectId) + : null; + + if (isCancelled) { + return; + } + + if (!persistedProject && collection.projects[0]) { + persistedProject = await projectStore.loadProject(collection.projects[0].id); + collection = await projectStore.setActiveProjectId( + persistedProject?.id ?? collection.projects[0].id, + ); + } + + if (!persistedProject) { + persistedProject = createBlankProjectDocument({ + existingProjectIds: collection.projects.map((project) => project.id), + name: DEFAULT_PROJECT_NAME, + }); + await projectStore.saveProject(persistedProject); + collection = await projectStore.setActiveProjectId(persistedProject.id); + } + + const { blobMap, missingSampleIds } = + await loadImportedSampleBlobMap(persistedProject); + + if (isCancelled) { + return; + } + + applyProjectDocument({ + importedSampleBlobs: blobMap, + project: persistedProject, + }); + projectSummariesRef.current = collection.projects; + setProjectSummaries(collection.projects); + reportMissingImportedSampleIds(missingSampleIds); + } catch (error) { + if (isCancelled) { + return; + } + + shouldEnableAutosave = false; + const message = + error instanceof Error ? error.message : "Project restore failed."; + durablePersistenceErrorRef.current = message; + setPersistenceStatus("error"); + setPersistenceError(message); + } finally { + if (!isCancelled && shouldEnableAutosave) { + setIsPersistenceReady(true); + } + } + } + + void restoreProject(); + + return () => { + isCancelled = true; + }; + // Restore once before autosave starts; project switch handlers own later loads. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!isPersistenceReady || isProjectOperationPending || !activeProjectId) { + return; + } + + let isCancelled = false; + const timeoutId = window.setTimeout(() => { + const projectDocument = createPersistedProjectDocument({ + arrangementLengthBars, + arrangementLoopRange, + arrangementTracks, + clipInstances, + clips, + createdAt: activeProjectCreatedAt, + id: activeProjectId, + masterMixerState, + name: projectName, + sampleMetas, + tempoBpm: bpm, + trackMixerStates, + }); + + setPersistenceStatus("saving"); + projectStore + .saveProject(projectDocument) + .then(async () => { + if (isCancelled) { + return; + } + + const collection = await projectStore.loadProjectCollection(); + + if (isCancelled) { + return; + } + + projectSummariesRef.current = collection.projects; + setProjectSummaries(collection.projects); + + if (durablePersistenceErrorRef.current) { + setPersistenceStatus("error"); + setPersistenceError(durablePersistenceErrorRef.current); + } else { + setPersistenceStatus("saved"); + setPersistenceError(null); + } + }) + .catch((error: unknown) => { + if (isCancelled) { + return; + } + + setPersistenceStatus("error"); + setPersistenceError( + error instanceof Error ? error.message : "Project autosave failed.", + ); + }); + }, AUTOSAVE_DEBOUNCE_MS); + + return () => { + isCancelled = true; + window.clearTimeout(timeoutId); + }; + }, [ + arrangementLoopRange, + arrangementLengthBars, + arrangementTracks, + activeProjectCreatedAt, + activeProjectId, + bpm, + clipInstances, + clips, + isProjectOperationPending, + isPersistenceReady, + masterMixerState, + projectName, + sampleMetas, + trackMixerStates, + ]); + + useEffect(() => { + trackMixerStatesRef.current = trackMixerStates; + audioEngine.setTrackMixerStates(trackMixerStates); + }, [trackMixerStates]); + + useEffect(() => { + masterMixerStateRef.current = masterMixerState; + audioEngine.setMasterMixerState(masterMixerState); + }, [masterMixerState]); + + useEffect(() => { + return () => { + audioEngine.stopCachedSamplePreview(); + }; + }, []); + + useEffect(() => { + if (transportState !== "playing") { + return; + } + + let animationFrameId = 0; + + function updatePlayhead() { + const currentTick = audioEngine.getTransportSnapshot().currentTick; + + playheadTickRef.current = currentTick; + setPlayheadTick(currentTick); + animationFrameId = window.requestAnimationFrame(updatePlayhead); + } + + animationFrameId = window.requestAnimationFrame(updatePlayhead); + + return () => { + window.cancelAnimationFrame(animationFrameId); + }; + }, [transportState]); + + useEffect(() => { + if (transportMode !== "song" || transportState !== "playing") { + return; + } + + let animationFrameId = 0; + const trackIds = arrangementTracks.map((track) => track.id); + + function updateMixerLevels() { + setMixerLevels(audioEngine.getMixerLevels(trackIds)); + animationFrameId = window.requestAnimationFrame(updateMixerLevels); + } + + animationFrameId = window.requestAnimationFrame(updateMixerLevels); + + return () => { + window.cancelAnimationFrame(animationFrameId); + }; + }, [arrangementTracks, transportMode, transportState]); + + function commitSelectedClip( + nextClip: HybridClip, + { syncPlayback = true }: { syncPlayback?: boolean } = {}, + ) { + const nextClips = clipsRef.current.map((clip) => + clip.id === nextClip.id ? nextClip : clip, + ); + + selectedClipRef.current = nextClip; + clipsRef.current = nextClips; + setClips(nextClips); + + if (!syncPlayback) { + return; + } + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips); + } else if (transportState === "playing") { + void updatePlayingClipEvents(nextClip); + } + } + + function commitAnyClip(nextClip: Clip) { + const nextClips = clipsRef.current.map((clip) => + clip.id === nextClip.id ? nextClip : clip, + ); + + clipsRef.current = nextClips; + setClips(nextClips); + + if (nextClip.id === selectedClipRef.current.id) { + selectedClipRef.current = nextClip; + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips); + } else if (transportState === "playing" && isHybridClip(nextClip)) { + void updatePlayingClipEvents(nextClip); + } + } + } + + function commitClip(nextClip: HybridClip) { + commitAnyClip(nextClip); + } + + function selectClipAndInstrument( + clip: HybridClip, + instrumentId: Exclude, + ) { + selectedClipRef.current = clip; + setSelectedClipId(clip.id); + setSelectedInstrumentId(instrumentId); + + if (instrumentId !== "drums") { + setSelectedPitchedInstrumentId(instrumentId); + return; + } + + setSelectedPitchedInstrumentId( + clip.pitchedInstrumentIds[0] ?? DEFAULT_PITCHED_INSTRUMENT_ID, + ); + } + + function selectClipDefault(clip: Clip) { + selectedClipRef.current = clip; + setSelectedClipId(clip.id); + + if (isAudioClip(clip)) { + setSelectedInstrumentId("audio"); + return; + } + + selectClipAndInstrument(clip, clip.pitchedInstrumentIds[0] ?? "drums"); + } + + function commitPlayheadTick(nextTick: Tick) { + playheadTickRef.current = nextTick; + setPlayheadTick(nextTick); + } + + function commitBpm(nextBpm: number) { + const normalizedBpm = clampTempoBpm(nextBpm); + const snapshot = audioEngine.setTempoBpm(normalizedBpm); + + bpmRef.current = snapshot.tempoBpm; + setBpm(snapshot.tempoBpm); + commitPlayheadTick(snapshot.currentTick); + } + + function commitClipInstances(nextClipInstances: ClipInstance[]) { + clipInstancesRef.current = nextClipInstances; + setClipInstances(nextClipInstances); + } + + function commitArrangementLoopRange( + nextLoopRange: ArrangementLoopRange, + lengthBars = arrangementLengthBarsRef.current, + ) { + const normalizedLoopRange = normalizeArrangementLoopRange( + nextLoopRange, + lengthBars, + ); + + arrangementLoopRangeRef.current = normalizedLoopRange; + setArrangementLoopRange(normalizedLoopRange); + } + + function handleTrackVolumeChange(trackId: string, volumeDb: number) { + setTrackMixerStates((currentStates) => + updateTrackMixerState(currentStates, trackId, { volumeDb }), + ); + } + + function handleTrackMuteToggle(trackId: string) { + setTrackMixerStates((currentStates) => { + const currentState = currentStates.find( + (state) => state.trackId === trackId, + ); + + return updateTrackMixerState(currentStates, trackId, { + muted: !(currentState?.muted ?? false), + }); + }); + } + + function handleTrackSoloToggle(trackId: string) { + setTrackMixerStates((currentStates) => { + const currentState = currentStates.find( + (state) => state.trackId === trackId, + ); + + return updateTrackMixerState(currentStates, trackId, { + solo: !(currentState?.solo ?? false), + }); + }); + } + + function handleMasterVolumeChange(volumeDb: number) { + setMasterMixerState((currentState) => + updateMasterMixerState(currentState, { volumeDb }), + ); + } + + function getSelectedHybridClip(): HybridClip | null { + const clip = selectedClipRef.current; + + return isHybridClip(clip) ? clip : null; + } + + function markAudioClipPreviewStopped() { + setIsAudioClipPreviewPlaying(false); + } + + function stopAudioClipPreview() { + audioEngine.stopCachedSamplePreview(); + markAudioClipPreviewStopped(); + } + + function reportPersistenceError(message: string) { + setPersistenceStatus("error"); + setPersistenceError(message); + } + + function reportMissingImportedSampleIds(missingSampleIds: readonly string[]) { + if (missingSampleIds.length > 0) { + const message = `Missing imported audio data for ${missingSampleIds.join( + ", ", + )}.`; + + durablePersistenceErrorRef.current = message; + setPersistenceStatus("error"); + setPersistenceError(message); + return; + } + + durablePersistenceErrorRef.current = null; + setPersistenceStatus("saved"); + setPersistenceError(null); + } + + function createCurrentProjectDocument({ + name = projectNameRef.current, + savedAt = Date.now(), + }: { + name?: string; + savedAt?: number; + } = {}): PersistedProjectDocument | null { + if (!activeProjectIdRef.current) { + return null; + } + + return createPersistedProjectDocument({ + arrangementLengthBars: arrangementLengthBarsRef.current, + arrangementLoopRange: arrangementLoopRangeRef.current, + arrangementTracks, + clipInstances: clipInstancesRef.current, + clips: clipsRef.current, + createdAt: activeProjectCreatedAtRef.current, + id: activeProjectIdRef.current, + masterMixerState: masterMixerStateRef.current, + name, + sampleMetas: sampleMetasRef.current, + savedAt, + tempoBpm: bpmRef.current, + trackMixerStates: trackMixerStatesRef.current, + }); + } + + async function saveCurrentProjectNow({ + name, + }: { + name?: string; + } = {}): Promise { + const projectDocument = createCurrentProjectDocument({ name }); + + if (!projectDocument) { + return null; + } + + setPersistenceStatus("saving"); + await projectStore.saveProject(projectDocument); + const collection = await projectStore.loadProjectCollection(); + + projectSummariesRef.current = collection.projects; + setProjectSummaries(collection.projects); + + if (durablePersistenceErrorRef.current) { + setPersistenceStatus("error"); + setPersistenceError(durablePersistenceErrorRef.current); + } else { + setPersistenceStatus("saved"); + setPersistenceError(null); + } + + return projectDocument; + } + + async function loadImportedSampleBlobMap( + project: PersistedProjectDocument, + ): Promise<{ + blobMap: Map; + missingSampleIds: string[]; + }> { + const importedSampleIds = getImportedSampleIds(project); + const importedSampleBlobs = await Promise.all( + importedSampleIds.map(async (sampleId) => ({ + blob: await projectStore.loadImportedSampleBlob(project.id, sampleId), + sampleId, + })), + ); + const blobMap = new Map(); + const missingSampleIds: string[] = []; + + for (const { blob, sampleId } of importedSampleBlobs) { + if (blob) { + blobMap.set(sampleId, blob); + } else { + missingSampleIds.push(sampleId); + } + } + + return { + blobMap, + missingSampleIds, + }; + } + + function applyProjectDocument({ + importedSampleBlobs, + project, + }: { + importedSampleBlobs: Map; + project: PersistedProjectDocument; + }) { + const restoredTracks = + project.arrangementTracks.length > 0 + ? project.arrangementTracks + : createDefaultArrangementTracks(); + const restoredClips = + project.clips.length > 0 + ? project.clips + : [createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" })]; + const restoredBpm = clampTempoBpm(project.tempoBpm); + const restoredArrangementLengthBars = normalizeArrangementLengthBars( + project.arrangementLengthBars, + ); + const restoredLoopRange = normalizeArrangementLoopRange( + project.arrangementLoopRange, + restoredArrangementLengthBars, + ); + const restoredTrackMixerStates = + project.trackMixerStates.length > 0 + ? project.trackMixerStates + : createDefaultTrackMixerStates(restoredTracks); + const restoredMasterMixerState = + project.masterMixerState ?? createDefaultMasterMixerState(); + const restoredClip = restoredClips[0]!; + + stopAudioClipPreview(); + audioEngine.stopLoop(); + setTransportState("stopped"); + commitPlayheadTick(0); + bpmRef.current = audioEngine.setTempoBpm(restoredBpm).tempoBpm; + activeProjectIdRef.current = project.id; + activeProjectCreatedAtRef.current = project.createdAt; + projectNameRef.current = project.name; + clipsRef.current = restoredClips; + arrangementLengthBarsRef.current = restoredArrangementLengthBars; + arrangementLoopRangeRef.current = restoredLoopRange; + clipInstancesRef.current = project.clipInstances; + sampleMetasRef.current = project.sampleMetas; + selectedClipRef.current = restoredClip; + trackMixerStatesRef.current = restoredTrackMixerStates; + masterMixerStateRef.current = restoredMasterMixerState; + importedSampleBlobsRef.current = importedSampleBlobs; + runtimeImportedSampleKeysRef.current = new Set(); + + setActiveProjectId(project.id); + setActiveProjectCreatedAt(project.createdAt); + setProjectName(project.name); + setBpm(bpmRef.current); + setClips(restoredClips); + setArrangementTracks(restoredTracks); + setArrangementLengthBars(restoredArrangementLengthBars); + setArrangementLoopRange(restoredLoopRange); + setClipInstances(project.clipInstances); + setSampleMetas(project.sampleMetas); + setTrackMixerStates(restoredTrackMixerStates); + setMasterMixerState(restoredMasterMixerState); + setMixerLevels(createEmptyMixerLevels(restoredTracks)); + setSelectedClipId(restoredClip.id); + setSelectedClipInstanceId(null); + setAudioError(null); + setClipImportError(null); + setArrangementExportError(null); + setIsAudioClipPreviewPlaying(false); + + if (isAudioClip(restoredClip)) { + setSelectedInstrumentId("audio"); + } else { + setSelectedInstrumentId(restoredClip.pitchedInstrumentIds[0] ?? "drums"); + setSelectedPitchedInstrumentId( + restoredClip.pitchedInstrumentIds[0] ?? DEFAULT_PITCHED_INSTRUMENT_ID, + ); + } + } + + async function openProject(projectId: string) { + const project = await projectStore.loadProject(projectId); + + if (!project) { + throw new Error("The selected project could not be loaded."); + } + + const { blobMap, missingSampleIds } = await loadImportedSampleBlobMap(project); + const collection = await projectStore.setActiveProjectId(project.id); + + applyProjectDocument({ + importedSampleBlobs: blobMap, + project, + }); + projectSummariesRef.current = collection.projects; + setProjectSummaries(collection.projects); + reportMissingImportedSampleIds(missingSampleIds); + } + + async function runProjectOperation(operation: () => Promise) { + setIsProjectOperationPending(true); + setIsPersistenceReady(false); + stopAudioClipPreview(); + audioEngine.stopLoop(); + setTransportState("stopped"); + commitPlayheadTick(0); + setMixerLevels(createEmptyMixerLevels(arrangementTracks)); + + try { + await operation(); + } catch (error) { + reportPersistenceError( + error instanceof Error ? error.message : "Project operation failed.", + ); + } finally { + setIsPersistenceReady(true); + setIsProjectOperationPending(false); + } + } + + function handleProjectCreate() { + const suggestedProjectName = createNextProjectName(projectSummariesRef.current); + const requestedProjectName = window.prompt( + "New project name", + suggestedProjectName, + ); + + if (requestedProjectName === null) { + return; + } + + const nextProjectName = requestedProjectName.trim() || suggestedProjectName; + + void runProjectOperation(async () => { + await saveCurrentProjectNow(); + + const project = createBlankProjectDocument({ + existingProjectIds: projectSummariesRef.current.map( + (projectSummary) => projectSummary.id, + ), + name: nextProjectName, + }); + + await projectStore.saveProject(project); + await openProject(project.id); + }); + } + + function handleProjectSelect(projectId: string) { + if (projectId === activeProjectIdRef.current || isProjectOperationPending) { + return; + } + + void runProjectOperation(async () => { + await saveCurrentProjectNow(); + await openProject(projectId); + }); + } + + function handleProjectRename() { + const requestedProjectName = window.prompt( + "Rename project", + projectNameRef.current, + ); + + if (requestedProjectName === null) { + return; + } + + const nextProjectName = requestedProjectName.trim(); + + if (!nextProjectName || nextProjectName === projectNameRef.current) { + return; + } + + void runProjectOperation(async () => { + const savedProject = await saveCurrentProjectNow({ + name: nextProjectName, + }); + + if (!savedProject) { + throw new Error("No active project is available to rename."); + } + + projectNameRef.current = savedProject.name; + setProjectName(savedProject.name); + }); + } + + function handleProjectDelete() { + const projectId = activeProjectIdRef.current; + const name = projectNameRef.current; + + if (!projectId || isProjectOperationPending) { + return; + } + + if ( + !window.confirm( + `Delete "${name}"? This removes the browser-local project and its imported sample data.`, + ) + ) { + return; + } + + void runProjectOperation(async () => { + let collection = await projectStore.deleteProject(projectId); + + if (!collection.activeProjectId) { + const project = createBlankProjectDocument({ + existingProjectIds: collection.projects.map( + (projectSummary) => projectSummary.id, + ), + name: DEFAULT_PROJECT_NAME, + }); + + await projectStore.saveProject(project); + collection = await projectStore.setActiveProjectId(project.id); + } + + await openProject(collection.activeProjectId); + }); + } + + async function persistImportedSampleBlob(sampleId: string, file: File) { + try { + const projectId = activeProjectIdRef.current; + + if (!projectId) { + throw new Error("No active project is available for imported audio."); + } + + await projectStore.saveImportedSampleBlob({ + blob: file, + fileName: file.name, + mimeType: file.type, + projectId, + sampleId, + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Imported audio file could not be saved locally."; + durablePersistenceErrorRef.current = message; + reportPersistenceError(message); + } + } + + async function ensureImportedAudioRuntimeSample( + clip: AudioClip, + ): Promise { + const projectId = activeProjectIdRef.current; + + if (!projectId) { + return false; + } + + const runtimeSampleKey = createRuntimeImportedSampleKey( + projectId, + clip.sampleId, + ); + + if (runtimeImportedSampleKeysRef.current.has(runtimeSampleKey)) { + return true; + } + + const blob = + importedSampleBlobsRef.current.get(clip.sampleId) ?? + (await projectStore.loadImportedSampleBlob(projectId, clip.sampleId)); + + if (!blob) { + return false; + } + + importedSampleBlobsRef.current.set(clip.sampleId, blob); + await audioEngine.importSampleBlob(clip.sampleId, blob, clip.sourceFileName); + runtimeImportedSampleKeysRef.current.add(runtimeSampleKey); + return true; + } + + async function getImportedSampleBlobsForArrangement( + instances: readonly ClipInstance[], + ): Promise> { + const importedSampleBlobs = new Map(importedSampleBlobsRef.current); + const missingClipNames: string[] = []; + + for (const instance of instances) { + const clip = clipsRef.current.find( + (candidate) => candidate.id === instance.clipId, + ); + + if (!clip || !isAudioClip(clip) || importedSampleBlobs.has(clip.sampleId)) { + continue; + } + + const projectId = activeProjectIdRef.current; + const blob = projectId + ? await projectStore.loadImportedSampleBlob(projectId, clip.sampleId) + : null; + + if (blob) { + importedSampleBlobs.set(clip.sampleId, blob); + importedSampleBlobsRef.current.set(clip.sampleId, blob); + continue; + } + + missingClipNames.push(clip.name); + } + + if (missingClipNames.length > 0) { + throw new Error( + `Imported audio data is missing for ${missingClipNames.join( + ", ", + )}. Re-import the file before exporting.`, + ); + } + + return importedSampleBlobs; + } + + async function handleAudioClipPreviewPlay() { + const clip = selectedClipRef.current; + + if (!isAudioClip(clip)) { + return; + } + + setAudioError(null); + + if (transportState !== "stopped") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + + try { + const hasRuntimeSample = await ensureImportedAudioRuntimeSample(clip); + + if (!hasRuntimeSample) { + throw new Error( + `Imported audio data is missing for ${clip.name}. Re-import the file.`, + ); + } + + await audioEngine.playCachedSample(clip.sampleId, { loop: true }); + setIsAudioClipPreviewPlaying(true); + } catch (error) { + markAudioClipPreviewStopped(); + setAudioError( + error instanceof Error ? error.message : "Audio clip preview failed.", + ); + } + } + + function handleDrumStepToggle( + laneId: DrumLaneId, + stepIndex: number, + substepIndex: number, + ) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + toggleDrumSubstep({ + clip, + laneId, + stepIndex, + substepIndex, + }), + ); + } + + function handleDrumStepSubdivisionChange( + subdivision: DrumStepSubdivision, + ) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + updateDrumStepSubdivision({ + clip, + subdivision, + }), + ); + } + + function handleClipLengthChange(barCount: HybridClipLengthBars) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + const lengthTicks = getHybridClipLengthTicks(barCount); + + if (clip.lengthTicks === lengthTicks) { + return; + } + + const shouldTrimEvents = + lengthTicks < clip.lengthTicks && + hasHybridClipEventsOutsideLength({ + clip, + lengthTicks, + }); + + if ( + shouldTrimEvents && + !window.confirm( + `Shorten ${clip.name} to ${barCount} bar${ + barCount === 1 ? "" : "s" + }? Events outside the new length will be removed or trimmed.`, + ) + ) { + return; + } + + try { + const nextClip = updateHybridClipLength({ + clip, + lengthTicks, + trimEvents: shouldTrimEvents, + }); + + commitSelectedClip(nextClip, { + syncPlayback: transportMode === "song", + }); + setAudioError(null); + + if (transportState === "playing" && transportMode !== "song") { + void restartPatternPlayback(nextClip); + } + } catch (error) { + setAudioError( + error instanceof Error ? error.message : "Clip length update failed.", + ); + } + } + + function handleLaneSampleChange( + laneId: DrumLaneId, + sample: BundledSampleMeta, + ) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + updateDrumLaneSample({ + clip, + label: sample.name, + laneId, + sampleId: sample.id, + }), + ); + } + + function handleLaneMove(laneId: DrumLaneId, targetIndex: number) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + moveDrumLane({ + clip, + laneId, + targetIndex, + }), + ); + } + + function handleNoteCreate({ + durationTicks, + midiNote, + startTick, + }: { + durationTicks: Tick; + midiNote: number; + startTick: Tick; + }) { + const clip = getSelectedHybridClip(); + + if ( + !clip || + !clip.pitchedInstrumentIds.includes(selectedPitchedInstrumentId) + ) { + return; + } + + commitSelectedClip( + addNoteEvent({ + clip, + durationTicks, + instrumentId: selectedPitchedInstrumentId, + midiNote, + startTick, + }), + ); + } + + function handleNoteDelete(noteId: string) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + deleteNoteEvent({ + clip, + noteId, + }), + ); + } + + function handleNoteMove({ + midiNote, + noteId, + startTick, + }: { + midiNote: number; + noteId: string; + startTick: Tick; + }) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + commitSelectedClip( + moveNoteEvent({ + clip, + midiNote, + noteId, + startTick, + }), + ); + } + + function handleClipAdd() { + const nextClip = createNextHybridClip(clipsRef.current); + const nextClips = [...clipsRef.current, nextClip]; + + clipsRef.current = nextClips; + setClips(nextClips); + selectClipAndInstrument( + nextClip, + nextClip.pitchedInstrumentIds[0] ?? "drums", + ); + + if (transportState === "playing" && transportMode !== "song") { + void updatePlayingClipEvents(nextClip); + } + } + + async function handleAudioClipImport(file: File) { + setAudioError(null); + setClipImportError(null); + stopAudioClipPreview(); + + try { + validateImportedWavFile(file); + } catch (error) { + const message = + error instanceof Error ? error.message : "Only WAV files can be imported."; + + setClipImportError(message); + setAudioError(message); + return; + } + + const { clipId, sampleId } = createImportedAudioIds({ + existingClipIds: clipsRef.current.map((clip) => clip.id), + existingSampleIds: sampleMetasRef.current.map((sampleMeta) => sampleMeta.id), + fileName: file.name, + }); + + setIsClipImporting(true); + + try { + const audioBuffer = await audioEngine.importSampleFile(sampleId, file); + const { clip, sampleMeta } = createImportedAudioClipDraft({ + clipId, + durationSeconds: audioBuffer.duration, + fileName: file.name, + mimeType: file.type, + sampleId, + }); + const nextClips = [...clipsRef.current, clip]; + const nextSampleMetas = [...sampleMetasRef.current, sampleMeta]; + + importedSampleBlobsRef.current.set(sampleId, file); + if (activeProjectIdRef.current) { + runtimeImportedSampleKeysRef.current.add( + createRuntimeImportedSampleKey(activeProjectIdRef.current, sampleId), + ); + } + clipsRef.current = nextClips; + sampleMetasRef.current = nextSampleMetas; + setClips(nextClips); + setSampleMetas(nextSampleMetas); + selectClipDefault(clip); + void persistImportedSampleBlob(sampleId, file); + + if (transportState === "playing" && transportMode !== "song") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to import the selected WAV file."; + + setClipImportError(message); + setAudioError(message); + } finally { + setIsClipImporting(false); + } + } + + async function handleArrangementWavExport() { + if (isArrangementExporting) { + return; + } + + setArrangementExportError(null); + setAudioError(null); + setIsArrangementExporting(true); + + try { + const importedSampleBlobs = await getImportedSampleBlobsForArrangement( + clipInstancesRef.current, + ); + const exportResult = await renderArrangementToWav({ + arrangementLengthBars: arrangementLengthBarsRef.current, + clipInstances: clipInstancesRef.current, + clips: clipsRef.current, + importedSampleBlobs, + masterMixerState: masterMixerStateRef.current, + tempoBpm: bpmRef.current, + trackMixerStates: trackMixerStatesRef.current, + }); + + downloadBlob( + exportResult.blob, + createArrangementExportFileName(projectNameRef.current), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Arrangement WAV export failed."; + + setArrangementExportError(message); + setAudioError(message); + } finally { + setIsArrangementExporting(false); + } + } + + function handleClipSelect(clipId: string) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip) { + return; + } + + stopAudioClipPreview(); + + if (isAudioClip(clip)) { + selectClipDefault(clip); + + if (transportState === "playing" && transportMode !== "song") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + + return; + } + + const nextInstrumentId = + selectedInstrumentId !== "drums" && + selectedInstrumentId !== "audio" && + clip.pitchedInstrumentIds.includes(selectedInstrumentId) + ? selectedInstrumentId + : "drums"; + + selectClipAndInstrument(clip, nextInstrumentId); + + if (transportState === "playing" && transportMode !== "song") { + void updatePlayingClipEvents(clip); + } + } + + function handleClipRename(clipId: string, name: string) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip) { + return; + } + + commitAnyClip(renameClip({ clip, name })); + } + + function handleClipDelete(clipId: string) { + const currentClips = clipsRef.current; + + if (currentClips.length <= 1) { + return; + } + + const clipIndex = currentClips.findIndex((clip) => clip.id === clipId); + const clip = currentClips[clipIndex]; + + if (!clip) { + return; + } + + const arrangementInstanceCount = clipInstancesRef.current.filter( + (instance) => instance.clipId === clipId, + ).length; + const confirmationMessage = getClipDeleteConfirmationMessage({ + arrangementInstanceCount, + clip, + }); + + if (confirmationMessage && !window.confirm(confirmationMessage)) { + return; + } + + const nextClips = currentClips.filter((candidate) => candidate.id !== clipId); + const fallbackClip = + nextClips[Math.max(0, Math.min(clipIndex, nextClips.length - 1))]; + + if (!fallbackClip) { + return; + } + + clipsRef.current = nextClips; + setClips(nextClips); + const nextClipInstances = clipInstancesRef.current.filter( + (instance) => instance.clipId !== clipId, + ); + + commitClipInstances(nextClipInstances); + + if ( + selectedClipInstanceId && + clipInstancesRef.current.every( + (instance) => instance.id !== selectedClipInstanceId, + ) + ) { + setSelectedClipInstanceId(null); + } + + if (clipId === selectedClipId) { + stopAudioClipPreview(); + selectClipDefault(fallbackClip); + + if ( + transportState === "playing" && + transportMode !== "song" && + isHybridClip(fallbackClip) + ) { + void updatePlayingClipEvents(fallbackClip); + } else if (transportState === "playing" && transportMode !== "song") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + } + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips, nextClipInstances); + } + } + + function handleInstrumentSelect(clipId: string, instrumentId: InstrumentId) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip || !isHybridClip(clip) || instrumentId === "audio") { + return; + } + + const isSelectingDifferentClip = clip.id !== selectedClipRef.current.id; + + selectClipAndInstrument(clip, instrumentId); + + if ( + transportState === "playing" && + transportMode !== "song" && + isSelectingDifferentClip + ) { + void updatePlayingClipEvents(clip); + } + } + + function handleInstrumentAdd( + clipId: string, + instrumentId: PitchedInstrumentId, + ) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip || !isHybridClip(clip)) { + return; + } + + const nextClip = addPitchedInstrumentToClip({ clip, instrumentId }); + const isSelectingDifferentClip = nextClip.id !== selectedClipRef.current.id; + + commitClip(nextClip); + selectClipAndInstrument(nextClip, instrumentId); + + if ( + transportState === "playing" && + transportMode !== "song" && + isSelectingDifferentClip + ) { + void updatePlayingClipEvents(nextClip); + } + } + + function handleInstrumentRemove( + clipId: string, + instrumentId: PitchedInstrumentId, + ) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip || !isHybridClip(clip)) { + return; + } + + const hasOwnedNotes = hasNoteEventsForPitchedInstrument({ + clip, + instrumentId, + }); + + if ( + hasOwnedNotes && + !window.confirm( + "Remove this instrument and delete its piano roll notes from the clip?", + ) + ) { + return; + } + + const nextClip = removePitchedInstrumentFromClip({ + clip, + instrumentId, + removeOwnedNotes: hasOwnedNotes, + }); + + commitClip(nextClip); + + if (clip.id === selectedClipId && selectedInstrumentId === instrumentId) { + selectClipAndInstrument( + nextClip, + nextClip.pitchedInstrumentIds[0] ?? "drums", + ); + } + } + + function handleArrangementClipDrop({ + clipId, + startTick, + trackId, + }: { + clipId: string; + startTick: Tick; + trackId: string; + }) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip) { + return; + } + + const nextInstance = createClipInstance({ + clip, + existingInstanceIds: clipInstancesRef.current.map( + (instance) => instance.id, + ), + startTick, + tempoBpm: bpmRef.current, + trackId, + }); + const nextClipInstances = [...clipInstancesRef.current, nextInstance]; + + commitClipInstances(nextClipInstances); + setSelectedClipInstanceId(nextInstance.id); + setAudioError(null); + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleClipInstanceMove({ + instanceId, + startTick, + trackId, + }: { + instanceId: string; + startTick: Tick; + trackId: string; + }) { + const nextClipInstances = clipInstancesRef.current.map((instance) => + instance.id === instanceId + ? moveClipInstance({ instance, startTick, trackId }) + : instance, + ); + + commitClipInstances(nextClipInstances); + setSelectedClipInstanceId(instanceId); + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleClipInstanceDelete(instanceId: string) { + const nextClipInstances = deleteClipInstance( + clipInstancesRef.current, + instanceId, + ); + + commitClipInstances(nextClipInstances); + + if (selectedClipInstanceId === instanceId) { + setSelectedClipInstanceId(null); + } + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleArrangementLengthChange(nextLengthBars: number) { + const normalizedLengthBars = normalizeArrangementLengthBars(nextLengthBars); + + if (normalizedLengthBars === arrangementLengthBarsRef.current) { + return; + } + + const currentClipInstances = clipInstancesRef.current; + const outOfRangeInstances = getClipInstancesOutsideArrangementLength({ + instances: currentClipInstances, + lengthBars: normalizedLengthBars, + }); + + if ( + outOfRangeInstances.length > 0 && + !window.confirm( + `Shorten arrangement to ${normalizedLengthBars} bar${ + normalizedLengthBars === 1 ? "" : "s" + }? ${outOfRangeInstances.length} clip placement${ + outOfRangeInstances.length === 1 ? "" : "s" + } beyond the new end will be removed.`, + ) + ) { + return; + } + + const nextClipInstances = + outOfRangeInstances.length > 0 + ? removeClipInstancesOutsideArrangementLength({ + instances: currentClipInstances, + lengthBars: normalizedLengthBars, + }) + : currentClipInstances; + const nextLoopRange = normalizeArrangementLoopRange( + arrangementLoopRangeRef.current, + normalizedLengthBars, + ); + + arrangementLengthBarsRef.current = normalizedLengthBars; + setArrangementLengthBars(normalizedLengthBars); + commitArrangementLoopRange(nextLoopRange, normalizedLengthBars); + + if (nextClipInstances !== currentClipInstances) { + commitClipInstances(nextClipInstances); + + if ( + selectedClipInstanceId && + nextClipInstances.every((instance) => instance.id !== selectedClipInstanceId) + ) { + setSelectedClipInstanceId(null); + } + } + + setAudioError(null); + + if (transportState === "playing" && transportMode === "song") { + void restartArrangementPlayback(playheadTickRef.current, nextLoopRange); + } + } + + function handleArrangementLoopRangeChange(nextLoopRange: ArrangementLoopRange) { + const normalizedLoopRange = normalizeArrangementLoopRange( + nextLoopRange, + arrangementLengthBarsRef.current, + ); + + commitArrangementLoopRange(normalizedLoopRange); + setAudioError(null); + + if (transportState === "playing" && transportMode === "song") { + void restartArrangementPlayback(normalizedLoopRange.startTick, normalizedLoopRange); + } + } + + function handleTransportModeChange(nextTransportMode: TransportMode) { + if (nextTransportMode === transportMode) { + return; + } + + stopAudioClipPreview(); + setMixerLevels(createEmptyMixerLevels(arrangementTracks)); + + if (transportState !== "stopped") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + + setTransportMode(nextTransportMode); + } + + async function restartArrangementPlayback( + startTick: Tick, + loopRange = arrangementLoopRangeRef.current, + ) { + try { + const snapshot = await startArrangementPlayback(startTick, loopRange); + + setTransportState("playing"); + commitPlayheadTick(snapshot.currentTick); + } catch (error) { + setTransportState("stopped"); + commitPlayheadTick(audioEngine.stopLoop().currentTick); + setMixerLevels(createEmptyMixerLevels(arrangementTracks)); + setAudioError( + error instanceof Error ? error.message : "Arrangement playback failed.", + ); + } + } + + async function startArrangementPlayback( + startTick: Tick, + loopRange = arrangementLoopRangeRef.current, + ) { + const currentClipInstances = clipInstancesRef.current; + const normalizedLoopRange = normalizeArrangementLoopRange( + loopRange, + arrangementLengthBarsRef.current, + ); + + if (currentClipInstances.length === 0) { + throw new Error("Place at least one clip in the arrangement before playback."); + } + + const missingImportedAudioClipNames = await getMissingImportedAudioRuntimeClipNames( + currentClipInstances, + ); + + if (missingImportedAudioClipNames.length > 0) { + throw new Error( + `Imported audio data is missing for ${missingImportedAudioClipNames.join( + ", ", + )}. Re-import the file in this session to play it.`, + ); + } + + const playbackEvents = buildArrangementPlaybackEvents({ + clipInstances: currentClipInstances, + clips: clipsRef.current, + }); + + audioEngine.setTrackMixerStates(trackMixerStatesRef.current); + audioEngine.setMasterMixerState(masterMixerStateRef.current); + + return audioEngine.startClipLoop({ + loopEndTick: normalizedLoopRange.endTick, + loopStartTick: normalizedLoopRange.startTick, + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + startTick: getArrangementPlaybackStartTick(startTick, normalizedLoopRange), + tempoBpm: bpmRef.current, + }); + } + + async function updatePlayingArrangementEvents( + nextClips = clipsRef.current, + nextClipInstances = clipInstancesRef.current, + ) { + setAudioError(null); + + try { + const missingImportedAudioClipNames = + await getMissingImportedAudioRuntimeClipNames(nextClipInstances); + + if (missingImportedAudioClipNames.length > 0) { + throw new Error( + `Imported audio data is missing for ${missingImportedAudioClipNames.join( + ", ", + )}. Re-import the file in this session to play it.`, + ); + } + + const playbackEvents = buildArrangementPlaybackEvents({ + clipInstances: nextClipInstances, + clips: nextClips, + }); + + await audioEngine.updateClipLoopEvents({ + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + }); + } catch (error) { + setAudioError( + error instanceof Error + ? error.message + : "Arrangement playback update failed.", + ); + } + } + + function buildArrangementPlaybackEvents({ + clipInstances: instances, + clips: sourceClips, + }: { + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + }) { + const playbackEvents = expandClipInstancesForPlayback({ + clipInstances: instances, + clips: sourceClips, + }); + + if (playbackEvents.missingClipIds.length > 0) { + throw new Error( + `Arrangement contains missing source clips: ${playbackEvents.missingClipIds.join( + ", ", + )}.`, + ); + } + + return playbackEvents; + } + + async function getMissingImportedAudioRuntimeClipNames( + instances: readonly ClipInstance[], + ): Promise { + const missingClipNames: string[] = []; + + for (const instance of instances) { + const clip = clipsRef.current.find( + (candidate) => candidate.id === instance.clipId, + ); + + if (!clip || !isAudioClip(clip)) { + continue; + } + + if (!(await ensureImportedAudioRuntimeSample(clip))) { + missingClipNames.push(clip.name); + } + } + + return missingClipNames; + } + + function getArrangementPlaybackStartTick( + startTick: Tick, + loopRange: ArrangementLoopRange, + ): Tick { + if (startTick >= loopRange.startTick && startTick < loopRange.endTick) { + return startTick; + } + + return loopRange.startTick; + } + + async function startPatternPlayback(clip: HybridClip, startTick: Tick) { + return audioEngine.startClipLoop({ + loopEndTick: clip.lengthTicks, + noteEvents: noteEventsToNoteLoopEvents( + clip.noteEvents, + ), + sampleEvents: drumEventsToSampleLoopEvents( + clip.drumEvents, + ), + startTick, + tempoBpm: bpmRef.current, + }); + } + + async function restartPatternPlayback( + clip: HybridClip, + startTick = playheadTickRef.current, + ) { + try { + const snapshot = await startPatternPlayback(clip, startTick); + + setTransportState("playing"); + commitPlayheadTick(snapshot.currentTick); + } catch (error) { + setTransportState("stopped"); + commitPlayheadTick(audioEngine.stopLoop().currentTick); + setAudioError( + error instanceof Error ? error.message : "Audio playback failed.", + ); + } + } + + async function handleTransportStateChange(nextTransportState: TransportState) { + setAudioError(null); + + if (nextTransportState === "stopped") { + stopAudioClipPreview(); + const snapshot = audioEngine.stopLoop(); + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + setMixerLevels(createEmptyMixerLevels(arrangementTracks)); + return; + } + + if (nextTransportState === "paused") { + stopAudioClipPreview(); + const snapshot = audioEngine.pauseLoop(); + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + setMixerLevels(createEmptyMixerLevels(arrangementTracks)); + return; + } + + const startTick = transportState === "paused" ? playheadTickRef.current : 0; + const clip = selectedClipRef.current; + stopAudioClipPreview(); + + if (transportMode === "song") { + setTransportState("playing"); + await restartArrangementPlayback(startTick); + + return; + } + + if (!isHybridClip(clip)) { + await handleAudioClipPreviewPlay(); + return; + } + + setTransportState("playing"); + + try { + const snapshot = await startPatternPlayback(clip, startTick); + commitPlayheadTick(snapshot.currentTick); + } catch (error) { + setTransportState("stopped"); + commitPlayheadTick(audioEngine.stopLoop().currentTick); + setAudioError( + error instanceof Error ? error.message : "Audio playback failed.", + ); + } + } + + async function updatePlayingClipEvents(clip: HybridClip) { + setAudioError(null); + + try { + await audioEngine.updateClipLoopEvents({ + noteEvents: noteEventsToNoteLoopEvents(clip.noteEvents), + sampleEvents: drumEventsToSampleLoopEvents(clip.drumEvents), + }); + } catch (error) { + setAudioError( + error instanceof Error ? error.message : "Audio clip update failed.", + ); + } + } + + return ( +
+ + +
+ + +
+ {transportMode === "song" ? ( + + ) : selectedAudioClip ? ( + <> +
+
+

Imported Audio Clip

+

{selectedAudioClip.name}

+
+
+ WAV + {selectedAudioClip.durationSeconds.toFixed(2)} sec + Stored locally + {audioError ? ( + {audioError} + ) : null} +
+
+ +
+ +
+ + ) : selectedHybridClip ? ( + <> +
+
+

M1 Hybrid Clip Editor

+

{selectedHybridClip.name}

+
+
+
+ {HYBRID_CLIP_LENGTH_BARS.map((barCount) => { + const isSelected = + getHybridClipBarCount(selectedHybridClip.lengthTicks) === + barCount; + + return ( + + ); + })} +
+ 4/4 + PPQ 480 + {selectedHybridClip.drumEvents.length} drum events + {selectedHybridClip.noteEvents.length} note events + {audioError ? ( + {audioError} + ) : null} +
+
+ +
+ + +
+ + ) : ( + null + )} +
+
+
+ ); +} diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..713869c --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1 @@ +export { App } from "./App"; diff --git a/src/audio/arrangement-events.ts b/src/audio/arrangement-events.ts new file mode 100644 index 0000000..6728f12 --- /dev/null +++ b/src/audio/arrangement-events.ts @@ -0,0 +1,90 @@ +import { + isAudioClip, + isHybridClip, + type Clip, + type ClipInstance, +} from "../model"; +import type { NoteLoopEvent, SampleLoopEvent } from "./types"; + +export interface ArrangementPlaybackEvents { + missingClipIds: string[]; + noteEvents: NoteLoopEvent[]; + sampleEvents: SampleLoopEvent[]; +} + +export function expandClipInstancesForPlayback({ + clipInstances, + clips, +}: { + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; +}): ArrangementPlaybackEvents { + const clipsById = new Map(clips.map((clip) => [clip.id, clip])); + const missingClipIds: string[] = []; + const noteEvents: NoteLoopEvent[] = []; + const sampleEvents: SampleLoopEvent[] = []; + + for (const instance of clipInstances) { + const clip = clipsById.get(instance.clipId); + + if (!clip) { + missingClipIds.push(instance.clipId); + continue; + } + + if (isAudioClip(clip)) { + if (instance.lengthTicks > 0) { + sampleEvents.push({ + durationTicks: instance.lengthTicks, + id: `${instance.id}:audio`, + sampleId: clip.sampleId, + sourceOffsetSeconds: instance.sourceOffsetSeconds, + startTick: instance.startTick, + trackId: instance.trackId, + }); + } + + continue; + } + + if (!isHybridClip(clip)) { + continue; + } + + for (const event of clip.drumEvents) { + if (event.startTick >= instance.lengthTicks) { + continue; + } + + sampleEvents.push({ + gain: event.velocity, + id: `${instance.id}:${event.id}`, + sampleId: event.sampleId, + startTick: instance.startTick + event.startTick, + trackId: instance.trackId, + }); + } + + for (const event of clip.noteEvents) { + if (event.startTick >= instance.lengthTicks) { + continue; + } + + noteEvents.push({ + durationTicks: event.durationTicks, + gain: event.velocity, + id: `${instance.id}:${event.id}`, + instrumentId: event.instrumentId, + midiNote: event.midiNote, + startTick: instance.startTick + event.startTick, + trackId: instance.trackId, + }); + } + } + + return { + missingClipIds, + noteEvents, + sampleEvents, + }; +} diff --git a/src/audio/audio-buffer-decoder.ts b/src/audio/audio-buffer-decoder.ts new file mode 100644 index 0000000..2f71013 --- /dev/null +++ b/src/audio/audio-buffer-decoder.ts @@ -0,0 +1,160 @@ +interface DecodedPcmWav { + channelData: Float32Array[]; + sampleRate: number; +} + +export async function decodeAudioBuffer({ + arrayBuffer, + audioContext, + errorMessage, +}: { + arrayBuffer: ArrayBuffer; + audioContext: BaseAudioContext; + errorMessage: string; +}): Promise { + try { + return await audioContext.decodeAudioData(arrayBuffer.slice(0)); + } catch (decodeError) { + const decodedPcmWav = decodePcmWav(arrayBuffer); + + if (!decodedPcmWav) { + throw decodeError instanceof Error + ? new Error(errorMessage, { cause: decodeError }) + : new Error(errorMessage); + } + + const audioBuffer = audioContext.createBuffer( + decodedPcmWav.channelData.length, + decodedPcmWav.channelData[0]?.length ?? 0, + decodedPcmWav.sampleRate, + ); + + decodedPcmWav.channelData.forEach((channelData, channelIndex) => { + audioBuffer.copyToChannel(new Float32Array(channelData), channelIndex); + }); + + return audioBuffer; + } +} + +function decodePcmWav(arrayBuffer: ArrayBuffer): DecodedPcmWav | null { + const view = new DataView(arrayBuffer); + + if ( + arrayBuffer.byteLength < 44 || + readAscii(view, 0, 4) !== "RIFF" || + readAscii(view, 8, 4) !== "WAVE" + ) { + return null; + } + + let audioFormat = 0; + let bitsPerSample = 0; + let blockAlign = 0; + let channelCount = 0; + let dataOffset = 0; + let dataSize = 0; + let sampleRate = 0; + let offset = 12; + + while (offset + 8 <= view.byteLength) { + const chunkId = readAscii(view, offset, 4); + const chunkSize = view.getUint32(offset + 4, true); + const chunkStart = offset + 8; + + if (chunkStart + chunkSize > view.byteLength) { + return null; + } + + if (chunkId === "fmt ") { + audioFormat = view.getUint16(chunkStart, true); + channelCount = view.getUint16(chunkStart + 2, true); + sampleRate = view.getUint32(chunkStart + 4, true); + blockAlign = view.getUint16(chunkStart + 12, true); + bitsPerSample = view.getUint16(chunkStart + 14, true); + } + + if (chunkId === "data") { + dataOffset = chunkStart; + dataSize = chunkSize; + } + + offset = chunkStart + chunkSize + (chunkSize % 2); + } + + const isPcm = audioFormat === 1 || audioFormat === 65534; + const bytesPerSample = bitsPerSample / 8; + + if ( + !isPcm || + !Number.isInteger(bytesPerSample) || + ![2, 3, 4].includes(bytesPerSample) || + blockAlign <= 0 || + channelCount <= 0 || + dataOffset <= 0 || + dataSize <= 0 || + sampleRate <= 0 + ) { + return null; + } + + const frameCount = Math.floor(dataSize / blockAlign); + const channelData = Array.from( + { length: channelCount }, + () => new Float32Array(frameCount), + ); + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const frameOffset = dataOffset + frameIndex * blockAlign; + + for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) { + const sampleOffset = frameOffset + channelIndex * bytesPerSample; + + channelData[channelIndex][frameIndex] = readPcmSample( + view, + sampleOffset, + bytesPerSample, + ); + } + } + + return { + channelData, + sampleRate, + }; +} + +function readPcmSample( + view: DataView, + sampleOffset: number, + bytesPerSample: number, +): number { + if (bytesPerSample === 2) { + return view.getInt16(sampleOffset, true) / 32768; + } + + if (bytesPerSample === 3) { + let value = + view.getUint8(sampleOffset) | + (view.getUint8(sampleOffset + 1) << 8) | + (view.getUint8(sampleOffset + 2) << 16); + + if (value & 0x800000) { + value |= 0xff000000; + } + + return value / 8388608; + } + + return view.getInt32(sampleOffset, true) / 2147483648; +} + +function readAscii(view: DataView, offset: number, length: number): string { + let value = ""; + + for (let index = 0; index < length; index += 1) { + value += String.fromCharCode(view.getUint8(offset + index)); + } + + return value; +} diff --git a/src/audio/browser-audio-engine.ts b/src/audio/browser-audio-engine.ts new file mode 100644 index 0000000..ded518a --- /dev/null +++ b/src/audio/browser-audio-engine.ts @@ -0,0 +1,944 @@ +import { BUNDLED_SAMPLES } from "./bundled-samples"; +import { decodeAudioBuffer } from "./audio-buffer-decoder"; +import { LookaheadScheduler } from "./lookahead-scheduler"; +import { + resolveSamplerPlaybackPlan, + resolveSamplerVoiceRelease, +} from "./sampler-sustain"; +import type { + AudioEngine, + AudioEngineSnapshot, + BundledSampleMeta, + MixerLevelSnapshot, + NoteLoopEvent, + PlaySampleOptions, + SampleId, + SampleLoopEvent, + StartClipLoopOptions, + StartSampleLoopOptions, + TransportSnapshot, +} from "./types"; +import { + getPitchedInstrument, + getSampleZoneForMidiNote, + createDefaultMasterMixerState, + decibelsToLinearGain, + getTrackEffectiveGain, + getTrackMixerState, + type MasterMixerState, + type SampleZone, + type TrackId, + type TrackMixerState, +} from "../model"; +import { + DEFAULT_TEMPO_BPM, + TICKS_PER_4_4_BAR, + clampTempoBpm, + ticksToSeconds, +} from "../utils"; + +const DEFAULT_SAMPLE_GAIN = 0.9; +const DEFAULT_SYNTH_GAIN = 0.22; +const DEFAULT_SAMPLER_GAIN = 0.72; + +type AudioContextConstructor = new () => AudioContext; + +type ClipLoopEvent = + | ({ kind: "note" } & NoteLoopEvent) + | ({ kind: "sample" } & SampleLoopEvent); + +interface ActiveNoteVoice { + gainNode: GainNode; + isReleasing: boolean; + releaseSeconds: number; + sourceNode: AudioScheduledSourceNode; + startTime: number; +} + +interface ActiveSamplePreview { + gainNode: GainNode; + sourceNode: AudioBufferSourceNode; +} + +interface MixerRoute { + analyserNode: AnalyserNode; + gainNode: GainNode; + meterBuffer: Uint8Array; +} + +export function createAudioEngine( + samples: readonly BundledSampleMeta[] = BUNDLED_SAMPLES, +): AudioEngine { + return new BrowserAudioEngine(samples); +} + +export class BrowserAudioEngine implements AudioEngine { + private readonly samplesById: Map; + private readonly sampleCache = new Map(); + private readonly loadingSamples = new Map>(); + private readonly activeNoteVoices = new Set(); + private readonly activeSampleVoices = new Set(); + private activeSamplePreview: ActiveSamplePreview | null = null; + private audioContext: AudioContext | null = null; + private sampleLoopUpdateToken = 0; + private clipLoopScheduler: LookaheadScheduler | null = null; + private masterMixerState: MasterMixerState = createDefaultMasterMixerState(); + private masterRoute: MixerRoute | null = null; + private tempoBpm = DEFAULT_TEMPO_BPM; + private readonly trackMixerStatesById = new Map(); + private readonly trackRoutes = new Map(); + + constructor(samples: readonly BundledSampleMeta[]) { + this.samplesById = new Map(samples.map((sample) => [sample.id, sample])); + } + + getSnapshot(): AudioEngineSnapshot { + return { + contextState: this.audioContext?.state ?? "not-created", + loadedSampleIds: Array.from(this.sampleCache.keys()), + transport: this.getTransportSnapshot(), + }; + } + + getTransportSnapshot(): TransportSnapshot { + if (this.clipLoopScheduler) { + return this.clipLoopScheduler.getSnapshot(); + } + + return { + audioStartTime: null, + currentTick: 0, + loopEndTick: TICKS_PER_4_4_BAR, + loopStartTick: 0, + nextScheduleTick: 0, + status: "stopped", + tempoBpm: this.tempoBpm, + }; + } + + getMixerLevels(trackIds: readonly TrackId[] = []): MixerLevelSnapshot { + const trackLevels: Record = {}; + + for (const trackId of trackIds) { + const route = this.trackRoutes.get(trackId); + + trackLevels[trackId] = route ? getAnalyserLevel(route) : 0; + } + + return { + masterLevel: this.masterRoute ? getAnalyserLevel(this.masterRoute) : 0, + trackLevels, + }; + } + + async resume(): Promise { + const audioContext = this.getOrCreateAudioContext(); + + if (audioContext.state === "suspended") { + await audioContext.resume(); + } + + return this.getSnapshot(); + } + + async suspend(): Promise { + this.stopLoop(); + + if (this.audioContext && this.audioContext.state === "running") { + await this.audioContext.suspend(); + } + + return this.getSnapshot(); + } + + setMasterMixerState(state: MasterMixerState): void { + this.masterMixerState = { + volumeDb: state.volumeDb, + }; + this.applyMasterMixerGain(); + } + + setTrackMixerStates(states: readonly TrackMixerState[]): void { + this.trackMixerStatesById.clear(); + + for (const state of states) { + this.trackMixerStatesById.set(state.trackId, { ...state }); + } + + this.applyTrackMixerGains(); + } + + async loadSample(sampleId: SampleId): Promise { + const cachedBuffer = this.sampleCache.get(sampleId); + + if (cachedBuffer) { + return cachedBuffer; + } + + const pendingLoad = this.loadingSamples.get(sampleId); + + if (pendingLoad) { + return pendingLoad; + } + + const sample = this.getSample(sampleId); + const loadPromise = this.fetchAndDecodeSample(sample) + .then((audioBuffer) => { + this.sampleCache.set(sampleId, audioBuffer); + return audioBuffer; + }) + .finally(() => { + this.loadingSamples.delete(sampleId); + }); + + this.loadingSamples.set(sampleId, loadPromise); + return loadPromise; + } + + async loadAllSamples(): Promise { + await Promise.all( + Array.from(this.samplesById.keys(), (sampleId) => this.loadSample(sampleId)), + ); + + return this.getSnapshot(); + } + + async importSampleFile(sampleId: SampleId, file: File): Promise { + return this.importSampleBlob(sampleId, file, file.name); + } + + async importSampleBlob( + sampleId: SampleId, + blob: Blob, + fileName = "imported sample", + ): Promise { + await this.resume(); + + const audioContext = this.getOrCreateAudioContext(); + const arrayBuffer = await blob.arrayBuffer(); + const audioBuffer = await decodeAudioBuffer({ + arrayBuffer, + audioContext, + errorMessage: `Failed to decode imported WAV file "${fileName}".`, + }); + + this.loadingSamples.delete(sampleId); + this.sampleCache.set(sampleId, audioBuffer); + return audioBuffer; + } + + async playSample( + sampleId: SampleId, + options: PlaySampleOptions = {}, + ): Promise { + await this.resume(); + await this.loadSample(sampleId); + + this.scheduleLoadedSample(sampleId, options); + } + + async playCachedSample( + sampleId: SampleId, + options: PlaySampleOptions = {}, + ): Promise { + await this.resume(); + this.stopCachedSamplePreview(); + this.activeSamplePreview = this.scheduleLoadedSample(sampleId, options); + } + + async startSampleLoop({ + events, + loopEndTick = TICKS_PER_4_4_BAR, + loopStartTick = 0, + lookaheadMs, + ppq, + scheduleAheadTime, + startTick, + tempoBpm, + }: StartSampleLoopOptions): Promise { + return this.startClipLoop({ + loopEndTick, + loopStartTick, + lookaheadMs, + noteEvents: [], + ppq, + sampleEvents: events, + scheduleAheadTime, + startTick, + tempoBpm, + }); + } + + async startClipLoop({ + loopEndTick = TICKS_PER_4_4_BAR, + loopStartTick = 0, + lookaheadMs, + noteEvents, + ppq, + sampleEvents, + scheduleAheadTime, + startTick, + tempoBpm, + }: StartClipLoopOptions): Promise { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + + await this.resume(); + + await Promise.all([ + this.loadSamplesForLoopEvents(sampleEvents), + this.loadSamplesForNoteLoopEvents(noteEvents), + ]); + + this.stopLoop(); + + const audioContext = this.getOrCreateAudioContext(); + this.clipLoopScheduler = new LookaheadScheduler({ + events: createClipLoopEvents({ noteEvents, sampleEvents }), + getAudioTime: () => audioContext.currentTime, + lookaheadMs, + loopEndTick, + loopStartTick, + ppq, + scheduleAheadTime, + scheduleEvent: ({ audioTime, event, tempoBpm: scheduledTempoBpm }) => { + if (event.kind === "sample") { + this.scheduleLoadedSample(event.sampleId, { + gain: event.gain, + trackId: event.trackId, + when: audioTime, + }); + return; + } + + this.schedulePitchedNote(event, { + tempoBpm: scheduledTempoBpm, + when: audioTime, + }); + }, + tempoBpm: normalizedTempoBpm, + }); + this.tempoBpm = normalizedTempoBpm; + + return this.clipLoopScheduler.start({ startTick }); + } + + pauseLoop(): TransportSnapshot { + this.sampleLoopUpdateToken += 1; + this.stopActiveSampleVoices(); + this.stopActiveNoteVoices(); + + if (!this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + return this.clipLoopScheduler.pause(); + } + + stopLoop(): TransportSnapshot { + this.sampleLoopUpdateToken += 1; + this.stopActiveSampleVoices(); + this.stopActiveNoteVoices(); + + if (!this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + const snapshot = this.clipLoopScheduler.stop(); + this.tempoBpm = snapshot.tempoBpm; + this.clipLoopScheduler = null; + return snapshot; + } + + setTempoBpm(tempoBpm: number): TransportSnapshot { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + + this.tempoBpm = normalizedTempoBpm; + + if (!this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + return this.clipLoopScheduler.setTempoBpm(normalizedTempoBpm); + } + + async updateSampleLoopEvents( + events: readonly SampleLoopEvent[], + ): Promise { + return this.updateClipLoopEvents({ + noteEvents: [], + sampleEvents: events, + }); + } + + async updateClipLoopEvents({ + noteEvents, + sampleEvents, + }: { + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; + }): Promise { + if (!this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + const updateToken = (this.sampleLoopUpdateToken += 1); + + await Promise.all([ + this.loadSamplesForLoopEvents(sampleEvents), + this.loadSamplesForNoteLoopEvents(noteEvents), + ]); + + if (updateToken !== this.sampleLoopUpdateToken || !this.clipLoopScheduler) { + return this.getTransportSnapshot(); + } + + this.clipLoopScheduler.setEvents( + createClipLoopEvents({ noteEvents, sampleEvents }), + ); + return this.clipLoopScheduler.getSnapshot(); + } + + stopCachedSamplePreview(): void { + if (!this.activeSamplePreview) { + return; + } + + const activeSamplePreview = this.activeSamplePreview; + this.activeSamplePreview = null; + + this.stopAndDisconnectSampleVoice( + activeSamplePreview, + this.audioContext?.currentTime ?? 0, + ); + } + + private scheduleLoadedSample( + sampleId: SampleId, + options: PlaySampleOptions & { trackId?: TrackId } = {}, + ): ActiveSamplePreview { + const audioContext = this.getOrCreateAudioContext(); + const audioBuffer = this.sampleCache.get(sampleId); + + if (!audioBuffer) { + throw new Error(`Sample "${sampleId}" must be loaded before scheduling.`); + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + + sourceNode.buffer = audioBuffer; + sourceNode.loop = options.loop ?? false; + gainNode.gain.value = options.gain ?? DEFAULT_SAMPLE_GAIN; + sourceNode.connect(gainNode); + this.connectSourceGain(gainNode, options.trackId); + const sampleVoice = { + gainNode, + sourceNode, + }; + + this.activeSampleVoices.add(sampleVoice); + sourceNode.addEventListener( + "ended", + () => { + if (this.activeSamplePreview?.sourceNode === sourceNode) { + this.activeSamplePreview = null; + } + + this.activeSampleVoices.delete(sampleVoice); + disconnectAudioNode(sourceNode); + disconnectAudioNode(gainNode); + }, + { once: true }, + ); + sourceNode.start( + Math.max(options.when ?? audioContext.currentTime, audioContext.currentTime), + ); + + return sampleVoice; + } + + private schedulePitchedNote( + event: NoteLoopEvent, + { + tempoBpm, + when, + }: { + tempoBpm: number; + when: number; + }, + ): void { + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + this.scheduleSynthNote(event, { tempoBpm, when }); + return; + } + + const sampleZone = getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }); + + if (!sampleZone) { + this.scheduleSynthNote(event, { tempoBpm, when }); + return; + } + + this.scheduleSampledPitchedNote(event, { + sampleZone, + tempoBpm, + when, + }); + } + + private scheduleSynthNote( + event: NoteLoopEvent, + { + tempoBpm, + when, + }: { + tempoBpm: number; + when: number; + }, + ): void { + const audioContext = this.getOrCreateAudioContext(); + const sourceNode = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + const startTime = Math.max(when, audioContext.currentTime); + const durationSeconds = Math.max( + ticksToSeconds(event.durationTicks, { tempoBpm }), + 0.01, + ); + const stopTime = startTime + durationSeconds; + const attackSeconds = Math.min(0.01, durationSeconds / 4); + const releaseSeconds = Math.min(0.04, durationSeconds / 3); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = DEFAULT_SYNTH_GAIN * (event.gain ?? 1); + const synthVoice: ActiveNoteVoice = { + gainNode, + isReleasing: false, + releaseSeconds, + sourceNode, + startTime, + }; + + sourceNode.type = "triangle"; + sourceNode.frequency.setValueAtTime( + midiNoteToFrequency(event.midiNote), + startTime, + ); + + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + + sourceNode.connect(gainNode); + this.connectSourceGain(gainNode, event.trackId); + this.activeNoteVoices.add(synthVoice); + sourceNode.addEventListener( + "ended", + () => { + this.activeNoteVoices.delete(synthVoice); + disconnectAudioNode(sourceNode); + disconnectAudioNode(gainNode); + }, + { once: true }, + ); + + sourceNode.start(startTime); + sourceNode.stop(stopTime); + } + + private scheduleSampledPitchedNote( + event: NoteLoopEvent, + { + sampleZone, + tempoBpm, + when, + }: { + sampleZone: SampleZone; + tempoBpm: number; + when: number; + }, + ): void { + const audioContext = this.getOrCreateAudioContext(); + const { sampleId } = sampleZone; + const audioBuffer = this.sampleCache.get(sampleId); + + if (!audioBuffer) { + throw new Error(`Sample "${sampleId}" must be loaded before scheduling.`); + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + const startTime = Math.max(when, audioContext.currentTime); + const durationSeconds = Math.max( + ticksToSeconds(event.durationTicks, { tempoBpm }), + 0.01, + ); + const playbackRate = midiNoteToPlaybackRate( + event.midiNote, + sampleZone.rootMidiNote, + ); + const playbackPlan = resolveSamplerPlaybackPlan({ + bufferDurationSeconds: audioBuffer.duration, + noteDurationSeconds: durationSeconds, + playbackRate, + sampleZone, + }); + const noteStopTime = startTime + durationSeconds; + const unloopedSampleStopTime = + startTime + playbackPlan.unloopedPlaybackDurationSeconds; + const stopTime = playbackPlan.sustainLoopRegion + ? noteStopTime + : Math.min(noteStopTime, unloopedSampleStopTime); + const voiceDurationSeconds = Math.max(stopTime - startTime, 0.01); + const attackSeconds = Math.min( + playbackPlan.envelope.attackSeconds, + voiceDurationSeconds / 4, + ); + const releaseSeconds = Math.min( + playbackPlan.envelope.releaseSeconds, + voiceDurationSeconds / 2, + Math.max(voiceDurationSeconds - attackSeconds, 0), + ); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = DEFAULT_SAMPLER_GAIN * (event.gain ?? 1); + const sampleVoice: ActiveNoteVoice = { + gainNode, + isReleasing: false, + releaseSeconds, + sourceNode, + startTime, + }; + + sourceNode.buffer = audioBuffer; + sourceNode.playbackRate.setValueAtTime(playbackRate, startTime); + + if (playbackPlan.sustainLoopRegion) { + sourceNode.loop = true; + sourceNode.loopStart = playbackPlan.sustainLoopRegion.loopStartSeconds; + sourceNode.loopEnd = playbackPlan.sustainLoopRegion.loopEndSeconds; + } + + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + + sourceNode.connect(gainNode); + this.connectSourceGain(gainNode, event.trackId); + this.activeNoteVoices.add(sampleVoice); + sourceNode.addEventListener( + "ended", + () => { + this.activeNoteVoices.delete(sampleVoice); + disconnectAudioNode(sourceNode); + disconnectAudioNode(gainNode); + }, + { once: true }, + ); + + sourceNode.start(startTime, playbackPlan.sampleOffsetSeconds); + sourceNode.stop(stopTime); + } + + private stopActiveNoteVoices(): void { + const currentTime = this.audioContext?.currentTime ?? 0; + + for (const noteVoice of this.activeNoteVoices) { + if (noteVoice.isReleasing) { + continue; + } + + noteVoice.isReleasing = true; + + if (currentTime < noteVoice.startTime) { + this.stopAndDisconnectVoice(noteVoice, currentTime); + continue; + } + + const release = resolveSamplerVoiceRelease({ + currentTime, + releaseSeconds: noteVoice.releaseSeconds, + }); + + try { + noteVoice.gainNode.gain.cancelScheduledValues(release.releaseStartTime); + noteVoice.gainNode.gain.setValueAtTime( + Math.max(noteVoice.gainNode.gain.value, 0), + release.releaseStartTime, + ); + noteVoice.gainNode.gain.linearRampToValueAtTime(0, release.stopTime); + noteVoice.sourceNode.stop(release.stopTime); + } catch { + this.stopAndDisconnectVoice(noteVoice, currentTime); + } + } + } + + private stopActiveSampleVoices(): void { + const currentTime = this.audioContext?.currentTime ?? 0; + + this.activeSamplePreview = null; + + for (const sampleVoice of this.activeSampleVoices) { + this.stopAndDisconnectSampleVoice(sampleVoice, currentTime); + } + } + + private stopAndDisconnectSampleVoice( + sampleVoice: ActiveSamplePreview, + when: number, + ): void { + try { + sampleVoice.sourceNode.stop(when); + } catch { + // The source may already have ended. Disconnecting below is enough. + } + + this.activeSampleVoices.delete(sampleVoice); + disconnectAudioNode(sampleVoice.sourceNode); + disconnectAudioNode(sampleVoice.gainNode); + } + + private stopAndDisconnectVoice(noteVoice: ActiveNoteVoice, when: number): void { + try { + noteVoice.sourceNode.stop(when); + } catch { + // The source may already have a scheduled stop. Disconnecting below is enough. + } + + this.activeNoteVoices.delete(noteVoice); + disconnectAudioNode(noteVoice.sourceNode); + disconnectAudioNode(noteVoice.gainNode); + } + + private async loadSamplesForLoopEvents( + events: readonly SampleLoopEvent[], + ): Promise { + await Promise.all( + Array.from( + new Set(events.map((event) => event.sampleId)), + (sampleId) => this.loadSample(sampleId), + ), + ); + } + + private async loadSamplesForNoteLoopEvents( + events: readonly NoteLoopEvent[], + ): Promise { + await Promise.all( + Array.from( + new Set(events.flatMap((event) => this.getSampleIdsForNoteEvent(event))), + (sampleId) => this.loadSample(sampleId), + ), + ); + } + + private getSampleIdsForNoteEvent(event: NoteLoopEvent): SampleId[] { + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + return []; + } + + const sampleZone = getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }); + + return sampleZone ? [sampleZone.sampleId] : []; + } + + private async fetchAndDecodeSample( + sample: BundledSampleMeta, + ): Promise { + const audioContext = this.getOrCreateAudioContext(); + const response = await fetch(sample.path); + + if (!response.ok) { + throw new Error(`Failed to load sample "${sample.id}" from ${sample.path}.`); + } + + const arrayBuffer = await response.arrayBuffer(); + + return decodeAudioBuffer({ + arrayBuffer, + audioContext, + errorMessage: `Failed to decode bundled sample "${sample.id}".`, + }); + } + + private getSample(sampleId: SampleId): BundledSampleMeta { + const sample = this.samplesById.get(sampleId); + + if (!sample) { + throw new Error(`Unknown bundled sample ID: ${sampleId}`); + } + + return sample; + } + + private getOrCreateAudioContext(): AudioContext { + if (!this.audioContext || this.audioContext.state === "closed") { + const AudioContextClass = getAudioContextConstructor(); + this.audioContext = new AudioContextClass(); + this.masterRoute = null; + this.trackRoutes.clear(); + } + + return this.audioContext; + } + + private connectSourceGain(gainNode: GainNode, trackId?: TrackId): void { + if (!trackId) { + gainNode.connect(this.getOrCreateAudioContext().destination); + return; + } + + gainNode.connect(this.getOrCreateTrackRoute(trackId).gainNode); + } + + private getOrCreateTrackRoute(trackId: TrackId): MixerRoute { + const existingRoute = this.trackRoutes.get(trackId); + + if (existingRoute) { + return existingRoute; + } + + const audioContext = this.getOrCreateAudioContext(); + const route = createMixerRoute(audioContext); + + route.analyserNode.connect(this.getOrCreateMasterRoute().gainNode); + this.trackRoutes.set(trackId, route); + this.applyTrackMixerGains(); + + return route; + } + + private getOrCreateMasterRoute(): MixerRoute { + if (this.masterRoute) { + return this.masterRoute; + } + + const audioContext = this.getOrCreateAudioContext(); + const route = createMixerRoute(audioContext); + + route.analyserNode.connect(audioContext.destination); + this.masterRoute = route; + this.applyMasterMixerGain(); + + return route; + } + + private applyMasterMixerGain(): void { + if (!this.masterRoute) { + return; + } + + this.masterRoute.gainNode.gain.value = decibelsToLinearGain( + this.masterMixerState.volumeDb, + ); + } + + private applyTrackMixerGains(): void { + const trackStates = Array.from(this.trackMixerStatesById.values()); + + for (const [trackId, route] of this.trackRoutes) { + const trackState = getTrackMixerState(trackStates, trackId); + + route.gainNode.gain.value = getTrackEffectiveGain({ + allTrackStates: trackStates, + trackState, + }); + } + } +} + +function createClipLoopEvents({ + noteEvents, + sampleEvents, +}: { + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; +}): ClipLoopEvent[] { + return [ + ...sampleEvents.map((event) => ({ + ...event, + kind: "sample" as const, + })), + ...noteEvents.map((event) => ({ + ...event, + kind: "note" as const, + })), + ]; +} + +function midiNoteToFrequency(midiNote: number): number { + return 440 * 2 ** ((midiNote - 69) / 12); +} + +function midiNoteToPlaybackRate(midiNote: number, rootMidiNote: number): number { + return 2 ** ((midiNote - rootMidiNote) / 12); +} + +function disconnectAudioNode(audioNode: AudioNode): void { + try { + audioNode.disconnect(); + } catch { + // Nodes may already be disconnected after stop or suspend. + } +} + +function createMixerRoute(audioContext: AudioContext): MixerRoute { + const gainNode = audioContext.createGain(); + const analyserNode = audioContext.createAnalyser(); + + analyserNode.fftSize = 256; + gainNode.connect(analyserNode); + + return { + analyserNode, + gainNode, + meterBuffer: new Uint8Array(new ArrayBuffer(analyserNode.fftSize)), + }; +} + +function getAnalyserLevel(route: MixerRoute): number { + route.analyserNode.getByteTimeDomainData(route.meterBuffer); + + let sumSquares = 0; + + for (const value of route.meterBuffer) { + const centeredValue = (value - 128) / 128; + + sumSquares += centeredValue * centeredValue; + } + + const rms = Math.sqrt(sumSquares / route.meterBuffer.length); + + return Math.min(1, rms * 3); +} + +function getAudioContextConstructor(): AudioContextConstructor { + const audioWindow = window as Window & + typeof globalThis & { + webkitAudioContext?: AudioContextConstructor; + }; + const AudioContextClass = + audioWindow.AudioContext ?? audioWindow.webkitAudioContext; + + if (!AudioContextClass) { + throw new Error("Web Audio API is not supported in this browser."); + } + + return AudioContextClass; +} diff --git a/src/audio/bundled-samples.ts b/src/audio/bundled-samples.ts new file mode 100644 index 0000000..16125dd --- /dev/null +++ b/src/audio/bundled-samples.ts @@ -0,0 +1,65 @@ +import type { BundledSampleMeta } from "./types"; + +const bundledDrumSamplePaths = [ + "/samples/drums/Fred_Clap_1.wav", + "/samples/drums/Fred_Clap_2.wav", + "/samples/drums/Fred_Clap_3.wav", + "/samples/drums/Fred_Closed_Hi-Hat.wav", + "/samples/drums/Fred_Kick_1.wav", + "/samples/drums/Fred_Kick_2.wav", + "/samples/drums/Fred_Kick_3.wav", + "/samples/drums/Fred_Open_Hi-Hat.wav", + "/samples/drums/Fred_Snare_1.wav", + "/samples/drums/Fred_Snare_2.wav", + "/samples/drums/Fred_Snare_3.wav", +] as const; + +const bundledPianoSamplePaths = [ + "/samples/pitched_instruments/Iowa_Piano/C4.wav", + "/samples/pitched_instruments/Iowa_Piano/Db4.wav", + "/samples/pitched_instruments/Iowa_Piano/D4.wav", + "/samples/pitched_instruments/Iowa_Piano/Eb4.wav", + "/samples/pitched_instruments/Iowa_Piano/E4.wav", + "/samples/pitched_instruments/Iowa_Piano/F4.wav", + "/samples/pitched_instruments/Iowa_Piano/Gb4.wav", + "/samples/pitched_instruments/Iowa_Piano/G4.wav", + "/samples/pitched_instruments/Iowa_Piano/Ab4.wav", + "/samples/pitched_instruments/Iowa_Piano/A4.wav", + "/samples/pitched_instruments/Iowa_Piano/Bb4.wav", + "/samples/pitched_instruments/Iowa_Piano/B4.wav", + "/samples/pitched_instruments/Iowa_Piano/C5.wav", +] as const; + +export const BUNDLED_DRUM_SAMPLES = bundledDrumSamplePaths.map((path) => ({ + id: getBundledSampleId(path), + name: getBundledSampleDisplayName(path), + path, +})) satisfies readonly BundledSampleMeta[]; + +export const BUNDLED_PIANO_SAMPLES = bundledPianoSamplePaths.map((path) => { + const noteName = getSampleFileStem(path); + + return { + id: `iowa-piano-${noteName.toLowerCase()}`, + name: `IOWA PIANO ${noteName.toUpperCase()}`, + path, + }; +}) satisfies readonly BundledSampleMeta[]; + +export const BUNDLED_SAMPLES = [ + ...BUNDLED_DRUM_SAMPLES, + ...BUNDLED_PIANO_SAMPLES, +] satisfies readonly BundledSampleMeta[]; + +export function getBundledSampleDisplayName(path: string): string { + return getSampleFileStem(path).replaceAll("_", " ").toUpperCase(); +} + +function getBundledSampleId(path: string): string { + return getSampleFileStem(path).replaceAll("_", "-").toLowerCase(); +} + +function getSampleFileStem(path: string): string { + const fileName = path.split("/").at(-1) ?? path; + return fileName.replace(/\.wav$/i, ""); +} diff --git a/src/audio/index.ts b/src/audio/index.ts new file mode 100644 index 0000000..77034cd --- /dev/null +++ b/src/audio/index.ts @@ -0,0 +1,57 @@ +export { BrowserAudioEngine, createAudioEngine } from "./browser-audio-engine"; +export { expandClipInstancesForPlayback } from "./arrangement-events"; +export { renderArrangementToWav } from "./offline-arrangement-renderer"; +export { + encodePcm16WavArrayBuffer, + encodePcm16WavBlob, +} from "./wav-encoder"; +export { + BUNDLED_DRUM_SAMPLES, + BUNDLED_PIANO_SAMPLES, + BUNDLED_SAMPLES, +} from "./bundled-samples"; +export { + LookaheadScheduler, + collectScheduledEventsForWindow, + getLoopTickAtAbsoluteTick, +} from "./lookahead-scheduler"; +export { + resolveSamplerEnvelope, + resolveSamplerLoopRegion, + resolveSamplerPlaybackPlan, + resolveSamplerVoiceRelease, +} from "./sampler-sustain"; +export type { + ArrangementPlaybackEvents, +} from "./arrangement-events"; +export type { + ArrangementWavExportOptions, + ArrangementWavExportResult, +} from "./offline-arrangement-renderer"; +export type { + LookaheadSchedulerOptions, + ScheduledTickEvent, + ScheduleWindowOptions, + SchedulerSnapshot, + SchedulerStatus, + TickEvent, +} from "./lookahead-scheduler"; +export type { + ResolvedSamplerEnvelope, + SamplerLoopRegion, + SamplerPlaybackPlan, + SamplerVoiceRelease, +} from "./sampler-sustain"; +export type { + AudioEngine, + AudioEngineSnapshot, + BundledSampleMeta, + MixerLevelSnapshot, + NoteLoopEvent, + PlaySampleOptions, + SampleId, + SampleLoopEvent, + StartClipLoopOptions, + StartSampleLoopOptions, + TransportSnapshot, +} from "./types"; diff --git a/src/audio/lookahead-scheduler.ts b/src/audio/lookahead-scheduler.ts new file mode 100644 index 0000000..6ac59f5 --- /dev/null +++ b/src/audio/lookahead-scheduler.ts @@ -0,0 +1,398 @@ +import { + DEFAULT_PPQ, + TICKS_PER_4_4_BAR, + audioTimeToTick, + tickToAudioTime, + type Tick, +} from "../utils"; + +export type SchedulerStatus = "stopped" | "playing" | "paused"; + +export interface TickEvent { + id: string; + startTick: Tick; +} + +export interface ScheduledTickEvent { + event: TEvent; + audioTime: number; + absoluteTick: Tick; + loopIteration: number; + loopTick: Tick; + tempoBpm: number; +} + +export interface SchedulerSnapshot { + status: SchedulerStatus; + tempoBpm: number; + currentTick: Tick; + loopStartTick: Tick; + loopEndTick: Tick; + nextScheduleTick: Tick; + audioStartTime: number | null; +} + +export interface ScheduleWindowOptions { + audioStartTime: number; + events: readonly TEvent[]; + loopEndTick?: Tick; + loopStartTick?: Tick; + ppq?: number; + startTick?: Tick; + tempoBpm: number; + windowEndTick: Tick; + windowStartTick: Tick; +} + +export interface LookaheadSchedulerOptions { + events: readonly TEvent[]; + getAudioTime: () => number; + scheduleEvent: (scheduledEvent: ScheduledTickEvent) => void; + clearIntervalFn?: ClearSchedulerInterval; + lookaheadMs?: number; + loopEndTick?: Tick; + loopStartTick?: Tick; + ppq?: number; + scheduleAheadTime?: number; + setIntervalFn?: SetSchedulerInterval; + tempoBpm: number; +} + +export interface StartSchedulerOptions { + startTick?: Tick; +} + +const DEFAULT_LOOKAHEAD_MS = 25; +const DEFAULT_SCHEDULE_AHEAD_TIME = 0.1; + +type SchedulerTimerId = ReturnType; +type SetSchedulerInterval = ( + handler: () => void, + timeoutMs: number, +) => SchedulerTimerId; +type ClearSchedulerInterval = (timerId: SchedulerTimerId) => void; + +const defaultSetSchedulerInterval: SetSchedulerInterval = (handler, timeoutMs) => + globalThis.setInterval(handler, timeoutMs); +const defaultClearSchedulerInterval: ClearSchedulerInterval = (timerId) => + globalThis.clearInterval(timerId); + +export function collectScheduledEventsForWindow({ + audioStartTime, + events, + loopEndTick = TICKS_PER_4_4_BAR, + loopStartTick = 0, + ppq = DEFAULT_PPQ, + startTick = loopStartTick, + tempoBpm, + windowEndTick, + windowStartTick, +}: ScheduleWindowOptions): ScheduledTickEvent[] { + validateLoopRange(loopStartTick, loopEndTick); + + if (windowEndTick <= windowStartTick) { + return []; + } + + const loopLengthTicks = loopEndTick - loopStartTick; + const scheduledEvents: ScheduledTickEvent[] = []; + + for (const event of events) { + if (event.startTick < loopStartTick || event.startTick >= loopEndTick) { + continue; + } + + const eventOffsetTicks = event.startTick - loopStartTick; + const distanceToWindowStartTicks = + windowStartTick - loopStartTick - eventOffsetTicks; + const firstLoopIteration = Math.max( + 0, + Math.ceil(distanceToWindowStartTicks / loopLengthTicks), + ); + let loopIteration = firstLoopIteration; + + while (true) { + const absoluteTick = + loopStartTick + loopIteration * loopLengthTicks + eventOffsetTicks; + + if (absoluteTick >= windowEndTick) { + break; + } + + if (absoluteTick < windowStartTick) { + loopIteration += 1; + continue; + } + + scheduledEvents.push({ + absoluteTick, + audioTime: tickToAudioTime({ + audioStartTime, + ppq, + startTick, + tempoBpm, + tick: absoluteTick, + }), + event, + loopIteration, + loopTick: event.startTick, + tempoBpm, + }); + + loopIteration += 1; + } + } + + return scheduledEvents.sort((left, right) => left.absoluteTick - right.absoluteTick); +} + +export function getLoopTickAtAbsoluteTick({ + absoluteTick, + loopEndTick = TICKS_PER_4_4_BAR, + loopStartTick = 0, +}: { + absoluteTick: Tick; + loopEndTick?: Tick; + loopStartTick?: Tick; +}): Tick { + validateLoopRange(loopStartTick, loopEndTick); + + const loopLengthTicks = loopEndTick - loopStartTick; + const loopOffsetTicks = + ((absoluteTick - loopStartTick) % loopLengthTicks + loopLengthTicks) % + loopLengthTicks; + + return loopStartTick + loopOffsetTicks; +} + +export class LookaheadScheduler { + private readonly clearIntervalFn: ClearSchedulerInterval; + private readonly getAudioTime: () => number; + private readonly lookaheadMs: number; + private readonly scheduleAheadTime: number; + private readonly scheduleEvent: (scheduledEvent: ScheduledTickEvent) => void; + private readonly setIntervalFn: SetSchedulerInterval; + private audioStartTime: number | null = null; + private events: readonly TEvent[]; + private nextScheduleTick: Tick; + private startTick: Tick; + private status: SchedulerStatus = "stopped"; + private tempoBpm: number; + private timerId: SchedulerTimerId | null = null; + + readonly loopEndTick: Tick; + readonly loopStartTick: Tick; + readonly ppq: number; + + constructor({ + clearIntervalFn = defaultClearSchedulerInterval, + events, + getAudioTime, + lookaheadMs = DEFAULT_LOOKAHEAD_MS, + loopEndTick = TICKS_PER_4_4_BAR, + loopStartTick = 0, + ppq = DEFAULT_PPQ, + scheduleAheadTime = DEFAULT_SCHEDULE_AHEAD_TIME, + scheduleEvent, + setIntervalFn = defaultSetSchedulerInterval, + tempoBpm, + }: LookaheadSchedulerOptions) { + validateLoopRange(loopStartTick, loopEndTick); + validateTempoBpm(tempoBpm); + + this.clearIntervalFn = clearIntervalFn; + this.events = events; + this.getAudioTime = getAudioTime; + this.lookaheadMs = lookaheadMs; + this.loopEndTick = loopEndTick; + this.loopStartTick = loopStartTick; + this.nextScheduleTick = loopStartTick; + this.ppq = ppq; + this.scheduleAheadTime = scheduleAheadTime; + this.scheduleEvent = scheduleEvent; + this.setIntervalFn = setIntervalFn; + this.startTick = loopStartTick; + this.tempoBpm = tempoBpm; + } + + start({ startTick = this.loopStartTick }: StartSchedulerOptions = {}): SchedulerSnapshot { + if (this.status === "playing") { + return this.getSnapshot(); + } + + this.startTick = this.normalizeLoopTick(startTick); + this.audioStartTime = this.getAudioTime(); + this.nextScheduleTick = this.startTick; + this.status = "playing"; + this.scheduleNextWindow(); + this.timerId = this.setIntervalFn( + () => this.scheduleNextWindow(), + this.lookaheadMs, + ); + + return this.getSnapshot(); + } + + pause(): SchedulerSnapshot { + if (this.status !== "playing") { + return this.getSnapshot(); + } + + this.startTick = this.getCurrentTick(); + this.clearTimer(); + this.status = "paused"; + this.nextScheduleTick = this.startTick; + this.audioStartTime = null; + + return this.getSnapshot(); + } + + stop(): SchedulerSnapshot { + this.clearTimer(); + + this.status = "stopped"; + this.nextScheduleTick = this.loopStartTick; + this.startTick = this.loopStartTick; + this.audioStartTime = null; + + return this.getSnapshot(); + } + + setEvents(events: readonly TEvent[]): void { + this.events = events; + } + + setTempoBpm(tempoBpm: number): SchedulerSnapshot { + validateTempoBpm(tempoBpm); + + if (this.status === "playing" && this.audioStartTime !== null) { + const currentAudioTime = this.getAudioTime(); + const currentAbsoluteTick = this.getCurrentAbsoluteTickAtAudioTime( + currentAudioTime, + ); + + this.audioStartTime = currentAudioTime; + this.nextScheduleTick = Math.max(this.nextScheduleTick, currentAbsoluteTick); + this.startTick = currentAbsoluteTick; + } + + this.tempoBpm = tempoBpm; + return this.getSnapshot(); + } + + getSnapshot(): SchedulerSnapshot { + return { + audioStartTime: this.audioStartTime, + currentTick: this.getCurrentTick(), + loopEndTick: this.loopEndTick, + loopStartTick: this.loopStartTick, + nextScheduleTick: this.nextScheduleTick, + status: this.status, + tempoBpm: this.tempoBpm, + }; + } + + private getCurrentTick(): Tick { + return getLoopTickAtAbsoluteTick({ + absoluteTick: this.getCurrentAbsoluteTick(), + loopEndTick: this.loopEndTick, + loopStartTick: this.loopStartTick, + }); + } + + private getCurrentAbsoluteTick(): Tick { + if (this.status !== "playing" || this.audioStartTime === null) { + return this.startTick; + } + + return this.getCurrentAbsoluteTickAtAudioTime(this.getAudioTime()); + } + + private getCurrentAbsoluteTickAtAudioTime(audioTime: number): Tick { + if (this.audioStartTime === null) { + return this.startTick; + } + + return audioTimeToTick({ + audioStartTime: this.audioStartTime, + audioTime, + ppq: this.ppq, + startTick: this.startTick, + tempoBpm: this.tempoBpm, + }); + } + + private scheduleNextWindow(): void { + if (this.status !== "playing" || this.audioStartTime === null) { + return; + } + + const currentAudioTime = this.getAudioTime(); + const currentAbsoluteTick = audioTimeToTick({ + audioStartTime: this.audioStartTime, + audioTime: currentAudioTime, + ppq: this.ppq, + startTick: this.startTick, + tempoBpm: this.tempoBpm, + }); + const scheduleUntilTick = audioTimeToTick({ + audioStartTime: this.audioStartTime, + audioTime: currentAudioTime + this.scheduleAheadTime, + ppq: this.ppq, + startTick: this.startTick, + tempoBpm: this.tempoBpm, + }); + const windowStartTick = Math.max(this.nextScheduleTick, currentAbsoluteTick); + const windowEndTick = Math.max(windowStartTick, scheduleUntilTick); + const scheduledEvents = collectScheduledEventsForWindow({ + audioStartTime: this.audioStartTime, + events: this.events, + loopEndTick: this.loopEndTick, + loopStartTick: this.loopStartTick, + ppq: this.ppq, + startTick: this.startTick, + tempoBpm: this.tempoBpm, + windowEndTick, + windowStartTick, + }); + + for (const scheduledEvent of scheduledEvents) { + this.scheduleEvent(scheduledEvent); + } + + this.nextScheduleTick = windowEndTick; + } + + private clearTimer(): void { + if (this.timerId !== null) { + this.clearIntervalFn(this.timerId); + this.timerId = null; + } + } + + private normalizeLoopTick(tick: Tick): Tick { + if (tick === this.loopEndTick) { + return this.loopStartTick; + } + + return getLoopTickAtAbsoluteTick({ + absoluteTick: tick, + loopEndTick: this.loopEndTick, + loopStartTick: this.loopStartTick, + }); + } +} + +function validateLoopRange(loopStartTick: Tick, loopEndTick: Tick): void { + if (loopEndTick <= loopStartTick) { + throw new Error( + `loopEndTick must be greater than loopStartTick. Received ${loopStartTick}-${loopEndTick}.`, + ); + } +} + +function validateTempoBpm(tempoBpm: number): void { + if (!Number.isFinite(tempoBpm) || tempoBpm <= 0) { + throw new Error(`tempoBpm must be a positive finite number. Received ${tempoBpm}.`); + } +} diff --git a/src/audio/offline-arrangement-renderer.ts b/src/audio/offline-arrangement-renderer.ts new file mode 100644 index 0000000..e744c08 --- /dev/null +++ b/src/audio/offline-arrangement-renderer.ts @@ -0,0 +1,587 @@ +import { BUNDLED_SAMPLES } from "./bundled-samples"; +import { decodeAudioBuffer } from "./audio-buffer-decoder"; +import { expandClipInstancesForPlayback } from "./arrangement-events"; +import { + resolveSamplerPlaybackPlan, +} from "./sampler-sustain"; +import { + encodePcm16WavBlob, +} from "./wav-encoder"; +import type { + BundledSampleMeta, + NoteLoopEvent, + SampleId, + SampleLoopEvent, +} from "./types"; +import { + decibelsToLinearGain, + getArrangementLengthTicks, + getPitchedInstrument, + getSampleZoneForMidiNote, + getTrackEffectiveGain, + getTrackMixerState, + type Clip, + type ClipInstance, + type MasterMixerState, + type SampleZone, + type TrackMixerState, +} from "../model"; +import { + clampTempoBpm, + ticksToSeconds, +} from "../utils"; + +export interface ArrangementWavExportOptions { + arrangementLengthBars: number; + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + importedSampleBlobs?: ReadonlyMap; + masterMixerState: MasterMixerState; + sampleRate?: number; + samples?: readonly BundledSampleMeta[]; + tempoBpm: number; + trackMixerStates: readonly TrackMixerState[]; +} + +export interface ArrangementWavExportResult { + blob: Blob; + durationSeconds: number; + format: { + bitDepth: 16; + channelCount: 2; + sampleRate: number; + }; +} + +const DEFAULT_EXPORT_SAMPLE_RATE = 44100; +const EXPORT_CHANNEL_COUNT = 2; +const DEFAULT_SAMPLE_GAIN = 0.9; +const DEFAULT_SYNTH_GAIN = 0.22; +const DEFAULT_SAMPLER_GAIN = 0.72; + +export async function renderArrangementToWav({ + arrangementLengthBars, + clipInstances, + clips, + importedSampleBlobs = new Map(), + masterMixerState, + sampleRate = DEFAULT_EXPORT_SAMPLE_RATE, + samples = BUNDLED_SAMPLES, + tempoBpm, + trackMixerStates, +}: ArrangementWavExportOptions): Promise { + const normalizedTempoBpm = clampTempoBpm(tempoBpm); + const arrangementLengthTicks = getArrangementLengthTicks(arrangementLengthBars); + const durationSeconds = ticksToSeconds(arrangementLengthTicks, { + tempoBpm: normalizedTempoBpm, + }); + const frameCount = Math.max(1, Math.ceil(durationSeconds * sampleRate)); + const offlineAudioContext = createOfflineAudioContext({ + channelCount: EXPORT_CHANNEL_COUNT, + frameCount, + sampleRate, + }); + const playbackEvents = expandClipInstancesForPlayback({ + clipInstances, + clips, + }); + + if (playbackEvents.missingClipIds.length > 0) { + throw new Error( + `Arrangement contains missing source clips: ${playbackEvents.missingClipIds.join( + ", ", + )}.`, + ); + } + + const sampleBuffers = await loadSamplesForOfflineRender({ + audioContext: offlineAudioContext, + importedSampleBlobs, + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + samples, + }); + const mixerOptions = { + masterMixerState, + trackMixerStates, + }; + + for (const event of playbackEvents.sampleEvents) { + scheduleOfflineSampleEvent({ + arrangementLengthTicks, + audioContext: offlineAudioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm: normalizedTempoBpm, + }); + } + + for (const event of playbackEvents.noteEvents) { + scheduleOfflineNoteEvent({ + arrangementLengthTicks, + audioContext: offlineAudioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm: normalizedTempoBpm, + }); + } + + const renderedBuffer = await offlineAudioContext.startRendering(); + const channelData = Array.from( + { length: EXPORT_CHANNEL_COUNT }, + (_, channelIndex) => + renderedBuffer.getChannelData( + Math.min(channelIndex, renderedBuffer.numberOfChannels - 1), + ), + ); + + return { + blob: encodePcm16WavBlob({ + channelData, + sampleRate: renderedBuffer.sampleRate, + }), + durationSeconds: renderedBuffer.duration, + format: { + bitDepth: 16, + channelCount: EXPORT_CHANNEL_COUNT, + sampleRate: renderedBuffer.sampleRate, + }, + }; +} + +async function loadSamplesForOfflineRender({ + audioContext, + importedSampleBlobs, + noteEvents, + sampleEvents, + samples, +}: { + audioContext: BaseAudioContext; + importedSampleBlobs: ReadonlyMap; + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; + samples: readonly BundledSampleMeta[]; +}): Promise> { + const sampleIds = new Set(); + + for (const event of sampleEvents) { + sampleIds.add(event.sampleId); + } + + for (const event of noteEvents) { + const sampleZone = getSampleZoneForNoteEvent(event); + + if (sampleZone) { + sampleIds.add(sampleZone.sampleId); + } + } + + const sampleBuffers = new Map(); + + await Promise.all( + Array.from(sampleIds, async (sampleId) => { + sampleBuffers.set( + sampleId, + await loadOfflineSampleBuffer({ + audioContext, + importedSampleBlobs, + sampleId, + samples, + }), + ); + }), + ); + + return sampleBuffers; +} + +async function loadOfflineSampleBuffer({ + audioContext, + importedSampleBlobs, + sampleId, + samples, +}: { + audioContext: BaseAudioContext; + importedSampleBlobs: ReadonlyMap; + sampleId: SampleId; + samples: readonly BundledSampleMeta[]; +}): Promise { + const bundledSample = samples.find((sample) => sample.id === sampleId); + + if (bundledSample) { + const response = await fetch(bundledSample.path); + + if (!response.ok) { + throw new Error( + `Failed to load sample "${sampleId}" from ${bundledSample.path}.`, + ); + } + + return decodeAudioBuffer({ + arrayBuffer: await response.arrayBuffer(), + audioContext, + errorMessage: `Failed to decode bundled sample "${sampleId}".`, + }); + } + + const importedSampleBlob = importedSampleBlobs.get(sampleId); + + if (!importedSampleBlob) { + throw new Error( + `Sample data is missing for "${sampleId}". Re-import the file before exporting.`, + ); + } + + return decodeAudioBuffer({ + arrayBuffer: await importedSampleBlob.arrayBuffer(), + audioContext, + errorMessage: `Failed to decode imported sample "${sampleId}".`, + }); +} + +function scheduleOfflineSampleEvent({ + arrangementLengthTicks, + audioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm, +}: { + arrangementLengthTicks: number; + audioContext: OfflineAudioContext; + event: SampleLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + tempoBpm: number; +}): void { + if (event.startTick >= arrangementLengthTicks) { + return; + } + + const audioBuffer = sampleBuffers.get(event.sampleId); + + if (!audioBuffer) { + throw new Error(`Sample "${event.sampleId}" must be loaded before rendering.`); + } + + const startSeconds = ticksToSeconds(event.startTick, { tempoBpm }); + const sourceOffsetSeconds = Math.max(event.sourceOffsetSeconds ?? 0, 0); + const mixerGain = getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + const gainValue = DEFAULT_SAMPLE_GAIN * (event.gain ?? 1) * mixerGain; + + if (gainValue <= 0 || sourceOffsetSeconds >= audioBuffer.duration) { + return; + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + const eventDurationSeconds = + event.durationTicks === undefined + ? undefined + : ticksToSeconds( + Math.min(event.durationTicks, arrangementLengthTicks - event.startTick), + { tempoBpm }, + ); + const bufferDurationSeconds = audioBuffer.duration - sourceOffsetSeconds; + const durationSeconds = + eventDurationSeconds === undefined + ? bufferDurationSeconds + : Math.min(eventDurationSeconds, bufferDurationSeconds); + + if (durationSeconds <= 0) { + return; + } + + sourceNode.buffer = audioBuffer; + gainNode.gain.value = gainValue; + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + + if (event.durationTicks === undefined) { + sourceNode.start(startSeconds, sourceOffsetSeconds); + } else { + sourceNode.start(startSeconds, sourceOffsetSeconds, durationSeconds); + } +} + +function scheduleOfflineNoteEvent({ + arrangementLengthTicks, + audioContext, + event, + mixerOptions, + sampleBuffers, + tempoBpm, +}: { + arrangementLengthTicks: number; + audioContext: OfflineAudioContext; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + tempoBpm: number; +}): void { + if (event.startTick >= arrangementLengthTicks) { + return; + } + + const durationTicks = Math.min( + event.durationTicks, + arrangementLengthTicks - event.startTick, + ); + + if (durationTicks <= 0) { + return; + } + + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, + }); + return; + } + + const sampleZone = getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }); + + if (!sampleZone) { + scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, + }); + return; + } + + scheduleOfflineSampledNote({ + audioContext, + durationTicks, + event, + mixerOptions, + sampleBuffers, + sampleZone, + tempoBpm, + }); +} + +function scheduleOfflineSynthNote({ + audioContext, + durationTicks, + event, + mixerOptions, + tempoBpm, +}: { + audioContext: OfflineAudioContext; + durationTicks: number; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + tempoBpm: number; +}): void { + const startTime = ticksToSeconds(event.startTick, { tempoBpm }); + const durationSeconds = Math.max(ticksToSeconds(durationTicks, { tempoBpm }), 0.01); + const stopTime = startTime + durationSeconds; + const attackSeconds = Math.min(0.01, durationSeconds / 4); + const releaseSeconds = Math.min(0.04, durationSeconds / 3); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = + DEFAULT_SYNTH_GAIN * + (event.gain ?? 1) * + getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + + if (gainValue <= 0) { + return; + } + + const sourceNode = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + sourceNode.type = "triangle"; + sourceNode.frequency.setValueAtTime( + midiNoteToFrequency(event.midiNote), + startTime, + ); + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + sourceNode.start(startTime); + sourceNode.stop(stopTime); +} + +function scheduleOfflineSampledNote({ + audioContext, + durationTicks, + event, + mixerOptions, + sampleBuffers, + sampleZone, + tempoBpm, +}: { + audioContext: OfflineAudioContext; + durationTicks: number; + event: NoteLoopEvent; + mixerOptions: MixerGainOptions; + sampleBuffers: ReadonlyMap; + sampleZone: SampleZone; + tempoBpm: number; +}): void { + const audioBuffer = sampleBuffers.get(sampleZone.sampleId); + + if (!audioBuffer) { + throw new Error( + `Sample "${sampleZone.sampleId}" must be loaded before rendering.`, + ); + } + + const startTime = ticksToSeconds(event.startTick, { tempoBpm }); + const durationSeconds = Math.max(ticksToSeconds(durationTicks, { tempoBpm }), 0.01); + const playbackRate = midiNoteToPlaybackRate( + event.midiNote, + sampleZone.rootMidiNote, + ); + const playbackPlan = resolveSamplerPlaybackPlan({ + bufferDurationSeconds: audioBuffer.duration, + noteDurationSeconds: durationSeconds, + playbackRate, + sampleZone, + }); + const noteStopTime = startTime + durationSeconds; + const unloopedSampleStopTime = + startTime + playbackPlan.unloopedPlaybackDurationSeconds; + const stopTime = playbackPlan.sustainLoopRegion + ? noteStopTime + : Math.min(noteStopTime, unloopedSampleStopTime); + const voiceDurationSeconds = Math.max(stopTime - startTime, 0.01); + const attackSeconds = Math.min( + playbackPlan.envelope.attackSeconds, + voiceDurationSeconds / 4, + ); + const releaseSeconds = Math.min( + playbackPlan.envelope.releaseSeconds, + voiceDurationSeconds / 2, + Math.max(voiceDurationSeconds - attackSeconds, 0), + ); + const sustainEndTime = Math.max( + startTime + attackSeconds, + stopTime - releaseSeconds, + ); + const gainValue = + DEFAULT_SAMPLER_GAIN * + (event.gain ?? 1) * + getMixerGain({ + ...mixerOptions, + trackId: event.trackId, + }); + + if (gainValue <= 0) { + return; + } + + const sourceNode = audioContext.createBufferSource(); + const gainNode = audioContext.createGain(); + + sourceNode.buffer = audioBuffer; + sourceNode.playbackRate.setValueAtTime(playbackRate, startTime); + + if (playbackPlan.sustainLoopRegion) { + sourceNode.loop = true; + sourceNode.loopStart = playbackPlan.sustainLoopRegion.loopStartSeconds; + sourceNode.loopEnd = playbackPlan.sustainLoopRegion.loopEndSeconds; + } + + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(gainValue, startTime + attackSeconds); + gainNode.gain.setValueAtTime(gainValue, sustainEndTime); + gainNode.gain.linearRampToValueAtTime(0, stopTime); + sourceNode.connect(gainNode); + gainNode.connect(audioContext.destination); + sourceNode.start(startTime, playbackPlan.sampleOffsetSeconds); + sourceNode.stop(stopTime); +} + +interface MixerGainOptions { + masterMixerState: MasterMixerState; + trackMixerStates: readonly TrackMixerState[]; +} + +function getMixerGain({ + masterMixerState, + trackId, + trackMixerStates, +}: MixerGainOptions & { + trackId?: string; +}): number { + const masterGain = decibelsToLinearGain(masterMixerState.volumeDb); + + if (!trackId) { + return masterGain; + } + + const trackState = getTrackMixerState(trackMixerStates, trackId); + + return ( + masterGain * + getTrackEffectiveGain({ + allTrackStates: trackMixerStates, + trackState, + }) + ); +} + +function getSampleZoneForNoteEvent(event: NoteLoopEvent): SampleZone | null { + const instrument = getPitchedInstrument(event.instrumentId); + + if (instrument.kind !== "sample") { + return null; + } + + return getSampleZoneForMidiNote({ + instrument, + midiNote: event.midiNote, + }) ?? null; +} + +function createOfflineAudioContext({ + channelCount, + frameCount, + sampleRate, +}: { + channelCount: number; + frameCount: number; + sampleRate: number; +}): OfflineAudioContext { + if (!globalThis.OfflineAudioContext) { + throw new Error("Offline audio rendering is not supported in this browser."); + } + + return new OfflineAudioContext(channelCount, frameCount, sampleRate); +} + +function midiNoteToFrequency(midiNote: number): number { + return 440 * 2 ** ((midiNote - 69) / 12); +} + +function midiNoteToPlaybackRate(midiNote: number, rootMidiNote: number): number { + return 2 ** ((midiNote - rootMidiNote) / 12); +} diff --git a/src/audio/sampler-sustain.ts b/src/audio/sampler-sustain.ts new file mode 100644 index 0000000..a208fbd --- /dev/null +++ b/src/audio/sampler-sustain.ts @@ -0,0 +1,246 @@ +import type { + SamplerEnvelopeMeta, + SamplerSustainMeta, + SampleZone, +} from "../model"; + +const DEFAULT_ATTACK_SECONDS = 0.012; +const DEFAULT_RELEASE_SECONDS = 0.08; +const DEFAULT_MINIMUM_LOOP_DURATION_SECONDS = 0.04; +const MINIMUM_SAMPLE_OFFSET_MARGIN_SECONDS = 0.01; +const MAXIMUM_TRANSPORT_RELEASE_SECONDS = 0.2; + +export interface SamplerLoopRegion { + loopEndSeconds: number; + loopStartSeconds: number; +} + +export interface ResolvedSamplerEnvelope { + attackSeconds: number; + releaseSeconds: number; +} + +export interface SamplerPlaybackPlan { + envelope: ResolvedSamplerEnvelope; + sampleDurationSeconds?: number; + sampleOffsetSeconds: number; + sustainLoopRegion: SamplerLoopRegion | null; + unloopedPlaybackDurationSeconds: number; +} + +export interface SamplerVoiceRelease { + releaseStartTime: number; + stopTime: number; +} + +export function resolveSamplerPlaybackPlan({ + bufferDurationSeconds, + minimumLoopDurationSeconds = DEFAULT_MINIMUM_LOOP_DURATION_SECONDS, + noteDurationSeconds, + playbackRate = 1, + sampleZone, +}: { + bufferDurationSeconds: number; + minimumLoopDurationSeconds?: number; + noteDurationSeconds: number; + playbackRate?: number; + sampleZone: SampleZone; +}): SamplerPlaybackPlan { + const sampleOffsetSeconds = resolveSampleOffsetSeconds({ + bufferDurationSeconds, + sampleStartSeconds: sampleZone.sampleStartSeconds, + }); + const sampleEndSeconds = resolveSampleEndSeconds({ + bufferDurationSeconds, + sampleEndSeconds: sampleZone.sampleEndSeconds, + sampleOffsetSeconds, + }); + const sustainLoopRegion = resolveSamplerLoopRegion({ + bufferDurationSeconds, + minimumLoopDurationSeconds, + noteDurationSeconds, + playbackRate, + sampleEndSeconds, + sampleStartSeconds: sampleOffsetSeconds, + sustain: sampleZone.sustain, + }); + const sampleDurationSeconds = + sampleEndSeconds === undefined ? undefined : sampleEndSeconds - sampleOffsetSeconds; + const unloopedSampleDurationSeconds = + sampleDurationSeconds ?? Math.max(bufferDurationSeconds - sampleOffsetSeconds, 0); + const unloopedPlaybackDurationSeconds = + unloopedSampleDurationSeconds / Math.max(playbackRate, Number.EPSILON); + + return { + envelope: resolveSamplerEnvelope({ + envelope: sampleZone.envelope, + noteDurationSeconds, + }), + sampleDurationSeconds, + sampleOffsetSeconds, + sustainLoopRegion, + unloopedPlaybackDurationSeconds, + }; +} + +export function resolveSamplerLoopRegion({ + bufferDurationSeconds, + minimumLoopDurationSeconds = DEFAULT_MINIMUM_LOOP_DURATION_SECONDS, + noteDurationSeconds, + playbackRate = 1, + sampleEndSeconds, + sampleStartSeconds, + sustain, +}: { + bufferDurationSeconds: number; + minimumLoopDurationSeconds?: number; + noteDurationSeconds: number; + playbackRate?: number; + sampleEndSeconds?: number; + sampleStartSeconds: number; + sustain?: SamplerSustainMeta; +}): SamplerLoopRegion | null { + if ( + sustain?.mode !== "forward-loop" || + !isNonNegativeFinite(bufferDurationSeconds) || + !isNonNegativeFinite(noteDurationSeconds) || + !isNonNegativeFinite(sampleStartSeconds) || + !isPositiveFinite(playbackRate) || + !isPositiveFinite(minimumLoopDurationSeconds) || + bufferDurationSeconds <= minimumLoopDurationSeconds + ) { + return null; + } + + const loopStartSeconds = sustain.loopStartSeconds; + const loopEndSeconds = sustain.loopEndSeconds; + + if ( + !isNonNegativeFinite(loopStartSeconds) || + !isNonNegativeFinite(loopEndSeconds) || + loopStartSeconds < sampleStartSeconds || + loopEndSeconds > bufferDurationSeconds || + loopEndSeconds <= loopStartSeconds || + loopEndSeconds - loopStartSeconds < minimumLoopDurationSeconds + ) { + return null; + } + + if (sampleEndSeconds !== undefined && loopEndSeconds > sampleEndSeconds) { + return null; + } + + const bufferPositionAtNoteEnd = + sampleStartSeconds + noteDurationSeconds * playbackRate; + + if (bufferPositionAtNoteEnd <= loopEndSeconds) { + return null; + } + + return { + loopEndSeconds, + loopStartSeconds, + }; +} + +export function resolveSamplerEnvelope({ + envelope, + noteDurationSeconds, +}: { + envelope?: SamplerEnvelopeMeta; + noteDurationSeconds: number; +}): ResolvedSamplerEnvelope { + const safeDurationSeconds = Math.max(noteDurationSeconds, 0.01); + const rawAttackSeconds = resolveDurationSeconds( + envelope?.attackSeconds, + DEFAULT_ATTACK_SECONDS, + ); + const rawReleaseSeconds = resolveDurationSeconds( + envelope?.releaseSeconds, + DEFAULT_RELEASE_SECONDS, + ); + const attackSeconds = Math.min(rawAttackSeconds, safeDurationSeconds / 4); + const releaseSeconds = Math.min( + rawReleaseSeconds, + safeDurationSeconds / 2, + Math.max(safeDurationSeconds - attackSeconds, 0), + ); + + return { + attackSeconds, + releaseSeconds, + }; +} + +export function resolveSamplerVoiceRelease({ + currentTime, + releaseSeconds, +}: { + currentTime: number; + releaseSeconds: number; +}): SamplerVoiceRelease { + const releaseStartTime = Number.isFinite(currentTime) ? Math.max(currentTime, 0) : 0; + const safeReleaseSeconds = Math.min( + resolveDurationSeconds(releaseSeconds, DEFAULT_RELEASE_SECONDS), + MAXIMUM_TRANSPORT_RELEASE_SECONDS, + ); + + return { + releaseStartTime, + stopTime: releaseStartTime + safeReleaseSeconds, + }; +} + +function resolveSampleOffsetSeconds({ + bufferDurationSeconds, + sampleStartSeconds, +}: { + bufferDurationSeconds: number; + sampleStartSeconds?: number; +}): number { + if (!isNonNegativeFinite(sampleStartSeconds)) { + return 0; + } + + if (!isPositiveFinite(bufferDurationSeconds)) { + return 0; + } + + return Math.min( + sampleStartSeconds, + Math.max(bufferDurationSeconds - MINIMUM_SAMPLE_OFFSET_MARGIN_SECONDS, 0), + ); +} + +function resolveSampleEndSeconds({ + bufferDurationSeconds, + sampleEndSeconds, + sampleOffsetSeconds, +}: { + bufferDurationSeconds: number; + sampleEndSeconds?: number; + sampleOffsetSeconds: number; +}): number | undefined { + if ( + !isNonNegativeFinite(sampleEndSeconds) || + !isPositiveFinite(bufferDurationSeconds) || + sampleEndSeconds <= sampleOffsetSeconds || + sampleEndSeconds > bufferDurationSeconds + ) { + return undefined; + } + + return sampleEndSeconds; +} + +function resolveDurationSeconds(value: number | undefined, fallback: number): number { + return isNonNegativeFinite(value) ? value : fallback; +} + +function isPositiveFinite(value: number | undefined): value is number { + return value !== undefined && Number.isFinite(value) && value > 0; +} + +function isNonNegativeFinite(value: number | undefined): value is number { + return value !== undefined && Number.isFinite(value) && value >= 0; +} diff --git a/src/audio/types.ts b/src/audio/types.ts new file mode 100644 index 0000000..aadb974 --- /dev/null +++ b/src/audio/types.ts @@ -0,0 +1,107 @@ +import type { SchedulerSnapshot } from "./lookahead-scheduler"; +import type { + MasterMixerState, + PitchedInstrumentId, + TrackId, + TrackMixerState, +} from "../model"; +import type { Tick } from "../utils"; + +export type SampleId = string; + +export interface BundledSampleMeta { + id: SampleId; + name: string; + path: string; +} + +export interface PlaySampleOptions { + gain?: number; + loop?: boolean; + when?: number; +} + +export interface SampleLoopEvent { + durationTicks?: Tick; + id: string; + sampleId: SampleId; + sourceOffsetSeconds?: number; + startTick: Tick; + gain?: number; + trackId?: TrackId; +} + +export interface NoteLoopEvent { + durationTicks: Tick; + gain?: number; + id: string; + instrumentId: PitchedInstrumentId; + midiNote: number; + startTick: Tick; + trackId?: TrackId; +} + +export interface StartSampleLoopOptions { + events: readonly SampleLoopEvent[]; + loopEndTick?: Tick; + loopStartTick?: Tick; + lookaheadMs?: number; + ppq?: number; + scheduleAheadTime?: number; + startTick?: Tick; + tempoBpm: number; +} + +export interface StartClipLoopOptions { + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; + loopEndTick?: Tick; + loopStartTick?: Tick; + lookaheadMs?: number; + ppq?: number; + scheduleAheadTime?: number; + startTick?: Tick; + tempoBpm: number; +} + +export type TransportSnapshot = SchedulerSnapshot; + +export interface AudioEngineSnapshot { + contextState: AudioContextState | "not-created"; + loadedSampleIds: SampleId[]; + transport: TransportSnapshot; +} + +export interface MixerLevelSnapshot { + masterLevel: number; + trackLevels: Record; +} + +export interface AudioEngine { + getSnapshot(): AudioEngineSnapshot; + getTransportSnapshot(): TransportSnapshot; + getMixerLevels(trackIds?: readonly TrackId[]): MixerLevelSnapshot; + importSampleBlob(sampleId: SampleId, blob: Blob, fileName?: string): Promise; + importSampleFile(sampleId: SampleId, file: File): Promise; + resume(): Promise; + setMasterMixerState(state: MasterMixerState): void; + setTrackMixerStates(states: readonly TrackMixerState[]): void; + suspend(): Promise; + loadSample(sampleId: SampleId): Promise; + loadAllSamples(): Promise; + playCachedSample(sampleId: SampleId, options?: PlaySampleOptions): Promise; + playSample(sampleId: SampleId, options?: PlaySampleOptions): Promise; + pauseLoop(): TransportSnapshot; + setTempoBpm(tempoBpm: number): TransportSnapshot; + startClipLoop(options: StartClipLoopOptions): Promise; + startSampleLoop(options: StartSampleLoopOptions): Promise; + stopCachedSamplePreview(): void; + stopLoop(): TransportSnapshot; + updateClipLoopEvents(options: { + noteEvents: readonly NoteLoopEvent[]; + sampleEvents: readonly SampleLoopEvent[]; + }): Promise; + updateSampleLoopEvents( + events: readonly SampleLoopEvent[], + ): Promise; +} diff --git a/src/audio/wav-encoder.ts b/src/audio/wav-encoder.ts new file mode 100644 index 0000000..00d35ff --- /dev/null +++ b/src/audio/wav-encoder.ts @@ -0,0 +1,98 @@ +export interface PcmWavEncodingOptions { + channelData: readonly Float32Array[]; + sampleRate: number; +} + +const WAV_HEADER_BYTES = 44; +const PCM_FORMAT = 1; +const PCM_16_BIT_BYTES_PER_SAMPLE = 2; + +export function encodePcm16WavBlob(options: PcmWavEncodingOptions): Blob { + return new Blob([encodePcm16WavArrayBuffer(options)], { + type: "audio/wav", + }); +} + +export function encodePcm16WavArrayBuffer({ + channelData, + sampleRate, +}: PcmWavEncodingOptions): ArrayBuffer { + validateEncodingOptions({ channelData, sampleRate }); + + const channelCount = channelData.length; + const frameCount = channelData[0]?.length ?? 0; + const blockAlign = channelCount * PCM_16_BIT_BYTES_PER_SAMPLE; + const byteRate = sampleRate * blockAlign; + const dataByteLength = frameCount * blockAlign; + const arrayBuffer = new ArrayBuffer(WAV_HEADER_BYTES + dataByteLength); + const view = new DataView(arrayBuffer); + + writeAscii(view, 0, "RIFF"); + view.setUint32(4, 36 + dataByteLength, true); + writeAscii(view, 8, "WAVE"); + writeAscii(view, 12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, PCM_FORMAT, true); + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, 16, true); + writeAscii(view, 36, "data"); + view.setUint32(40, dataByteLength, true); + + let byteOffset = WAV_HEADER_BYTES; + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) { + const sample = clampPcmSample(channelData[channelIndex]?.[frameIndex] ?? 0); + const pcmValue = sample < 0 ? sample * 32768 : sample * 32767; + + view.setInt16(byteOffset, Math.round(pcmValue), true); + byteOffset += PCM_16_BIT_BYTES_PER_SAMPLE; + } + } + + return arrayBuffer; +} + +function validateEncodingOptions({ + channelData, + sampleRate, +}: PcmWavEncodingOptions): void { + if (!Number.isInteger(sampleRate) || sampleRate <= 0) { + throw new Error(`sampleRate must be a positive integer. Received ${sampleRate}.`); + } + + if (channelData.length <= 0) { + throw new Error("At least one PCM channel is required."); + } + + const frameCount = channelData[0]?.length; + + if (frameCount === undefined) { + throw new Error("At least one PCM channel is required."); + } + + for (const [channelIndex, channel] of channelData.entries()) { + if (channel.length !== frameCount) { + throw new Error( + `All PCM channels must have the same frame count. Channel 0 has ${frameCount}, channel ${channelIndex} has ${channel.length}.`, + ); + } + } +} + +function clampPcmSample(sample: number): number { + if (!Number.isFinite(sample)) { + return 0; + } + + return Math.min(1, Math.max(-1, sample)); +} + +function writeAscii(view: DataView, offset: number, value: string): void { + for (let index = 0; index < value.length; index += 1) { + view.setUint8(offset + index, value.charCodeAt(index)); + } +} diff --git a/src/components/Icon/Icon.module.css b/src/components/Icon/Icon.module.css new file mode 100644 index 0000000..ba52ade --- /dev/null +++ b/src/components/Icon/Icon.module.css @@ -0,0 +1,4 @@ +.icon { + flex: 0 0 auto; + vertical-align: middle; +} diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx new file mode 100644 index 0000000..9972c15 --- /dev/null +++ b/src/components/Icon/Icon.tsx @@ -0,0 +1,18 @@ +import styles from "./Icon.module.css"; + +interface IconProps { + name: string; + className?: string; + "aria-hidden"?: boolean; +} + +export function Icon({ name, className, "aria-hidden": ariaHidden = true }: IconProps) { + return ( + + {name} + + ); +} diff --git a/src/components/Icon/index.ts b/src/components/Icon/index.ts new file mode 100644 index 0000000..65d727b --- /dev/null +++ b/src/components/Icon/index.ts @@ -0,0 +1 @@ +export { Icon } from "./Icon"; diff --git a/src/components/Panel/Panel.module.css b/src/components/Panel/Panel.module.css new file mode 100644 index 0000000..5e0b3f2 --- /dev/null +++ b/src/components/Panel/Panel.module.css @@ -0,0 +1,51 @@ +.panel { + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + box-shadow: var(--panel-inset-shadow); + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.header { + align-items: center; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + display: flex; + justify-content: space-between; + min-height: 42px; + padding: var(--space-gutter) var(--space-panel); +} + +.eyebrow { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + margin: 0 0 var(--space-unit); + text-transform: uppercase; +} + +.eyebrow:only-child { + margin-bottom: 0; +} + +.title { + color: var(--color-text); + font-size: var(--font-size-body); + line-height: var(--line-height-tight); + margin: 0; +} + +.actions { + align-items: center; + display: flex; + gap: var(--space-gutter); +} + +.content { + flex: 1; + min-height: 0; +} diff --git a/src/components/Panel/Panel.tsx b/src/components/Panel/Panel.tsx new file mode 100644 index 0000000..2506207 --- /dev/null +++ b/src/components/Panel/Panel.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +import styles from "./Panel.module.css"; + +interface PanelProps { + title?: string; + eyebrow?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +} + +export function Panel({ title, eyebrow, actions, children, className }: PanelProps) { + return ( +
+
+
+ {eyebrow ?

{eyebrow}

: null} + {title ?

{title}

: null} +
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +} diff --git a/src/components/Panel/index.ts b/src/components/Panel/index.ts new file mode 100644 index 0000000..79bed34 --- /dev/null +++ b/src/components/Panel/index.ts @@ -0,0 +1 @@ +export { Panel } from "./Panel"; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..0401caf --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,2 @@ +export { Icon } from "./Icon"; +export { Panel } from "./Panel"; diff --git a/src/features/arrangement-view/ArrangementView.module.css b/src/features/arrangement-view/ArrangementView.module.css new file mode 100644 index 0000000..4cbd58a --- /dev/null +++ b/src/features/arrangement-view/ArrangementView.module.css @@ -0,0 +1,506 @@ +.arrangementPanel { + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + box-shadow: var(--panel-inset-shadow); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.toolbar { + align-items: center; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + display: flex; + justify-content: space-between; + min-height: 46px; + padding: var(--space-gutter) var(--space-panel); +} + +.toolbarTitleGroup { + min-width: 0; +} + +.eyebrow { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + margin: 0; + text-transform: uppercase; +} + +.toolbarControls { + align-items: center; + display: flex; + gap: var(--space-gutter); + min-width: 0; +} + +.errorBadge { + color: var(--color-error); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + margin: 0; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.snapControl { + align-items: center; + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + display: flex; + gap: var(--space-unit); + min-height: 28px; + padding: 0 var(--space-gutter); +} + +.lengthControl { + align-items: center; + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + display: flex; + min-height: 28px; + overflow: hidden; +} + +.lengthButton { + align-items: center; + background: transparent; + border: 0; + color: var(--color-text-muted); + cursor: pointer; + display: inline-flex; + height: 28px; + justify-content: center; + padding: 0 var(--space-gutter); +} + +.lengthButton:hover:not(:disabled), +.lengthButton:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.lengthButton:disabled { + color: var(--color-text-dim); + cursor: not-allowed; + opacity: 0.45; +} + +.lengthValue { + border-left: 1px solid var(--color-outline-variant); + border-right: 1px solid var(--color-outline-variant); + color: var(--color-primary-strong); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + min-width: 64px; + padding: 0 var(--space-gutter); + text-align: center; + text-transform: uppercase; +} + +.controlLabel { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; +} + +.controlValue { + color: var(--color-primary-strong); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + text-transform: uppercase; +} + +.arrangementBody { + display: grid; + grid-template-columns: var(--arrangement-track-header-width) minmax(0, 1fr); + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.trackHeaderColumn { + background: var(--color-surface-container-lowest); + border-right: 1px solid var(--color-outline-variant); + display: grid; + grid-template-rows: var(--arrangement-ruler-height) minmax(0, 1fr); + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.rulerCorner { + align-items: center; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + color: var(--color-text-muted); + display: flex; + height: var(--arrangement-ruler-height); + padding: 0 var(--space-gutter); +} + +.trackRows { + display: grid; + grid-template-rows: repeat(var(--arrangement-track-count), minmax(0, 1fr)); + height: 100%; + overflow: hidden; +} + +.trackHeader { + align-items: center; + border-bottom: 1px solid var(--color-outline-variant); + display: flex; + gap: var(--space-gutter); + min-height: 0; + min-width: 0; + padding: 0 var(--space-panel); +} + +.trackHeader:hover { + background: var(--color-surface-container); +} + +.trackNameGroup { + flex: 1; + min-width: 0; +} + +.trackName { + color: var(--color-text); + display: block; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.trackNameMuted { + color: var(--color-text-dim); +} + +.trackStatus { + background: var(--color-outline-variant); + border-radius: 50%; + flex: 0 0 auto; + height: 10px; + opacity: 0.58; + width: 10px; +} + +.trackStatusActive { + background: var(--color-primary); + box-shadow: var(--playhead-shadow); + opacity: 1; +} + +.timelineScroller { + background: var(--color-surface-container-lowest); + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-color: var(--color-primary-container) var(--color-surface-container-lowest); + scrollbar-width: thin; +} + +.timelineScroller::-webkit-scrollbar { + height: 10px; +} + +.timelineScroller::-webkit-scrollbar-track { + background: var(--color-surface-container-lowest); + border-top: 1px solid var(--color-outline-variant); +} + +.timelineScroller::-webkit-scrollbar-thumb { + background: var(--color-primary-container); + border: 2px solid var(--color-surface-container-lowest); + border-radius: var(--radius-md); +} + +.timelineScroller::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} + +.timelineContent { + display: grid; + grid-template-rows: var(--arrangement-ruler-height) minmax(0, 1fr); + height: 100%; + width: var(--arrangement-timeline-width); +} + +.ruler { + align-items: end; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + color: var(--color-text-dim); + display: flex; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + height: var(--arrangement-ruler-height); + position: relative; +} + +.barMarker { + border-left: 1px solid var(--color-outline); + flex: 0 0 var(--arrangement-bar-width); + height: 18px; + padding-left: var(--space-unit); +} + +.timelineGrid { + background-color: var(--color-surface-container-lowest); + height: 100%; + overflow: hidden; + position: relative; + width: var(--arrangement-timeline-width); +} + +.laneGrid, +.beatGrid { + inset: 0; + pointer-events: none; + position: absolute; +} + +.laneGrid { + background-image: linear-gradient( + var(--color-outline-variant) 1px, + transparent 1px + ); + background-size: 100% calc(100% / var(--arrangement-track-count)); + opacity: 0.75; +} + +.beatGrid { + background-image: + linear-gradient( + 90deg, + var(--color-outline) 1px, + transparent 1px + ), + linear-gradient( + 90deg, + var(--color-outline-variant) 1px, + transparent 1px + ); + background-size: + var(--arrangement-bar-width) 100%, + var(--arrangement-beat-width) 100%; + opacity: 0.36; +} + +.clipLayer { + inset: 0; + position: absolute; +} + +.loopBoundary { + background: var(--color-primary); + bottom: 0; + box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 48%, transparent); + pointer-events: none; + position: absolute; + top: 0; + width: 1px; + z-index: 3; +} + +.loopRulerConnector { + background: var(--color-primary); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 52%, transparent); + height: 2px; + pointer-events: none; + position: absolute; + top: 7px; + z-index: 4; +} + +.loopHandle { + appearance: none; + background: transparent; + border: 0; + cursor: ew-resize; + height: 20px; + margin: 0; + padding: 0; + position: absolute; + top: -4px; + transform: translateX(-50%); + touch-action: none; + width: 24px; + z-index: 5; +} + +.loopHandle::before, +.loopHandle::after { + background: var(--color-primary); + border-radius: var(--radius-xs); + box-shadow: 0 0 6px color-mix(in srgb, var(--color-primary) 56%, transparent); + content: ""; + height: 2px; + position: absolute; + top: 9px; + width: 12px; +} + +.loopHandle::before { + left: 2px; + transform: rotate(42deg); + transform-origin: right center; +} + +.loopHandle::after { + right: 2px; + transform: rotate(-42deg); + transform-origin: left center; +} + +.loopHandle:hover::before, +.loopHandle:hover::after, +.loopHandle:focus-visible::before, +.loopHandle:focus-visible::after { + background: var(--color-primary-strong); +} + +.loopHandle:focus-visible { + outline: 1px solid var(--color-primary-strong); + outline-offset: 2px; +} + +.loopBoundaryEnd { + opacity: 0.7; +} + +.clipBlock { + appearance: none; + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + box-shadow: var(--panel-inset-shadow); + color: var(--color-text); + cursor: grab; + display: grid; + grid-template-rows: 14px minmax(0, 1fr); + font: inherit; + overflow: hidden; + padding: 0; + position: absolute; + text-align: left; + z-index: 2; +} + +.clipBlock:active { + cursor: grabbing; +} + +.clipBlock:hover { + filter: brightness(1.08); +} + +.clipBlockPrimary { + background: var(--color-primary-container); + border-color: var(--color-primary); + color: var(--color-on-primary-container); +} + +.clipBlockSecondary { + background: var(--color-secondary); + border-color: var(--color-secondary-fixed); + color: var(--color-on-secondary); +} + +.clipBlockSelected { + box-shadow: + 0 0 0 1px var(--color-primary-strong), + var(--panel-inset-shadow); + outline: 1px solid var(--color-primary-strong); + outline-offset: 1px; + z-index: 5; +} + +.clipHeader { + align-items: center; + display: flex; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + min-width: 0; + overflow: hidden; + padding: 0 var(--space-gutter); + text-transform: uppercase; + white-space: nowrap; +} + +.clipBlockPrimary .clipHeader { + background: var(--color-primary); + color: var(--color-on-primary); +} + +.clipBlockSecondary .clipHeader { + background: var(--color-secondary-fixed); + color: var(--color-on-secondary); +} + +.clipHeader span { + overflow: hidden; + text-overflow: ellipsis; +} + +.midiPreview, +.waveform { + color: currentColor; + height: 100%; + opacity: 0.58; + padding: var(--space-unit); + width: 100%; +} + +.midiPreview rect { + fill: currentColor; +} + +.playhead { + background: var(--color-error); + bottom: 0; + box-shadow: 0 0 8px color-mix(in srgb, var(--color-error) 60%, transparent); + pointer-events: none; + position: absolute; + top: calc(-1 * var(--arrangement-ruler-height)); + width: 1px; + z-index: 8; +} + +.playheadHandle { + background: var(--color-error); + display: block; + height: 10px; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%) rotate(45deg); + width: 10px; +} + +@media (max-width: 900px) { + .toolbar { + align-items: flex-start; + flex-direction: column; + gap: var(--space-gutter); + } +} diff --git a/src/features/arrangement-view/ArrangementView.tsx b/src/features/arrangement-view/ArrangementView.tsx new file mode 100644 index 0000000..d0fe8b4 --- /dev/null +++ b/src/features/arrangement-view/ArrangementView.tsx @@ -0,0 +1,595 @@ +import { + useRef, + type CSSProperties, + type DragEvent, + type KeyboardEvent, + type PointerEvent, +} from "react"; + +import { Icon } from "../../components"; +import type { MixerLevelSnapshot } from "../../audio"; +import { + ARRANGEMENT_CLIP_DRAG_TYPE, + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + getArrangementLoopBoundaryIndexForLength, + type ArrangementLoopRange, + type ArrangementTrack, + type Clip, + type ClipInstance, + isAudioClip, + type MasterMixerState, + type TrackMixerState, +} from "../../model"; +import { TICKS_PER_4_4_BAR, type Tick } from "../../utils"; +import styles from "./ArrangementView.module.css"; +import { MixerPanel } from "./MixerPanel"; + +const TRACK_HEADER_WIDTH = 192; +const RULER_HEIGHT = 32; +const BAR_WIDTH = 128; +const BEATS_PER_BAR = 4; +const CLIP_ROW_INSET = 4; + +type ArrangementStyle = CSSProperties & Record<`--${string}`, string>; + +interface ArrangementViewProps { + arrangementLengthBars: number; + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + errorMessage?: string | null; + loopRange: ArrangementLoopRange; + masterMixerState: MasterMixerState; + maxArrangementLengthBars: number; + minArrangementLengthBars: number; + mixerLevels: MixerLevelSnapshot; + onArrangementLengthChange: (lengthBars: number) => void; + onClipDrop: (placement: { + clipId: string; + startTick: Tick; + trackId: string; + }) => void; + onClipInstanceDelete: (instanceId: string) => void; + onClipInstanceMove: (placement: { + instanceId: string; + startTick: Tick; + trackId: string; + }) => void; + onClipInstanceSelect: (instanceId: string) => void; + onLoopRangeChange: (loopRange: ArrangementLoopRange) => void; + onMasterVolumeChange: (volumeDb: number) => void; + onTrackMuteToggle: (trackId: string) => void; + onTrackSoloToggle: (trackId: string) => void; + onTrackVolumeChange: (trackId: string, volumeDb: number) => void; + playheadTick: Tick; + selectedClipInstanceId: string | null; + shouldShowPlayhead: boolean; + trackMixerStates: readonly TrackMixerState[]; + tracks: readonly ArrangementTrack[]; +} + +type LoopBoundaryKind = "start" | "end"; + +export function ArrangementView({ + arrangementLengthBars, + clipInstances, + clips, + errorMessage = null, + loopRange, + masterMixerState, + maxArrangementLengthBars, + minArrangementLengthBars, + mixerLevels, + onArrangementLengthChange, + onClipDrop, + onClipInstanceDelete, + onClipInstanceMove, + onClipInstanceSelect, + onLoopRangeChange, + onMasterVolumeChange, + onTrackMuteToggle, + onTrackSoloToggle, + onTrackVolumeChange, + playheadTick, + selectedClipInstanceId, + shouldShowPlayhead, + trackMixerStates, + tracks, +}: ArrangementViewProps) { + const rulerRef = useRef(null); + const timelineGridRef = useRef(null); + const draggingLoopBoundaryRef = useRef(null); + const clipById = new Map(clips.map((clip) => [clip.id, clip])); + const trackIndexById = new Map( + tracks.map((track, index) => [track.id, index] as const), + ); + const activeTrackIds = new Set( + clipInstances.map((instance) => instance.trackId), + ); + const barNumbers = Array.from( + { length: arrangementLengthBars }, + (_, index) => index + 1, + ); + const timelineWidth = BAR_WIDTH * arrangementLengthBars; + const loopStartBoundaryIndex = getArrangementLoopBoundaryIndexForLength( + loopRange.startTick, + arrangementLengthBars, + ); + const loopEndBoundaryIndex = getArrangementLoopBoundaryIndexForLength( + loopRange.endTick, + arrangementLengthBars, + ); + const rootStyle: ArrangementStyle = { + "--arrangement-bar-width": `${BAR_WIDTH}px`, + "--arrangement-beat-width": `${BAR_WIDTH / BEATS_PER_BAR}px`, + "--arrangement-ruler-height": `${RULER_HEIGHT}px`, + "--arrangement-timeline-width": `${timelineWidth}px`, + "--arrangement-track-count": `${tracks.length}`, + "--arrangement-track-header-width": `${TRACK_HEADER_WIDTH}px`, + }; + + function handleTimelineDragOver(event: DragEvent) { + if (!hasArrangementDragPayload(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = event.dataTransfer.types.includes( + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + ) + ? "move" + : "copy"; + } + + function handleTimelineDrop(event: DragEvent) { + if (!hasArrangementDragPayload(event)) { + return; + } + + event.preventDefault(); + + const position = getDropPosition(event, timelineGridRef.current, tracks); + + if (!position) { + return; + } + + const instanceId = event.dataTransfer.getData( + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + ); + + if (instanceId) { + onClipInstanceMove({ + instanceId, + startTick: position.startTick, + trackId: position.trackId, + }); + return; + } + + const clipId = event.dataTransfer.getData(ARRANGEMENT_CLIP_DRAG_TYPE); + + if (clipId) { + onClipDrop({ + clipId, + startTick: position.startTick, + trackId: position.trackId, + }); + } + } + + function handleLoopPointerDown( + event: PointerEvent, + boundary: LoopBoundaryKind, + ) { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + draggingLoopBoundaryRef.current = boundary; + updateLoopBoundaryFromClientX(event.clientX, boundary); + } + + function handleLoopPointerMove(event: PointerEvent) { + const boundary = draggingLoopBoundaryRef.current; + + if (!boundary) { + return; + } + + event.preventDefault(); + updateLoopBoundaryFromClientX(event.clientX, boundary); + } + + function handleLoopPointerEnd(event: PointerEvent) { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + draggingLoopBoundaryRef.current = null; + } + + function updateLoopBoundaryFromClientX( + clientX: number, + boundary: LoopBoundaryKind, + ) { + const boundaryIndex = getBoundaryIndexFromClientX({ + arrangementLengthBars, + clientX, + ruler: rulerRef.current, + }); + + if (boundary === "start") { + onLoopRangeChange({ + endTick: loopRange.endTick, + startTick: boundaryIndex * TICKS_PER_4_4_BAR, + }); + return; + } + + onLoopRangeChange({ + endTick: boundaryIndex * TICKS_PER_4_4_BAR, + startTick: loopRange.startTick, + }); + } + + return ( +
+
+
+

ARRANGEMENT

+
+
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ Snap + Beat + +
+
+ + + {arrangementLengthBars} bars + + +
+
+
+ +
+ + +
+
+
+ {barNumbers.map((barNumber) => ( +
+ {barNumber} +
+ ))} + + +
+ + + ({ + active: activeTrackIds.has(track.id), + id: track.id, + name: track.name, + }))} + /> +
+ ); +} + +function ClipContent({ kind }: { kind: "audio" | "midi" }) { + if (kind === "audio") { + return ( + + ); + } + + return ( + + ); +} + +function getLoopRegionStyle(loopRange: ArrangementLoopRange): CSSProperties { + return { + left: `${tickToPixels(loopRange.startTick)}px`, + width: `${Math.max(1, tickToPixels(loopRange.endTick - loopRange.startTick))}px`, + }; +} + +function getBoundaryIndexFromClientX( + { + arrangementLengthBars, + clientX, + ruler, + }: { + arrangementLengthBars: number; + clientX: number; + ruler: HTMLDivElement | null; + }, +): number { + if (!ruler) { + return 0; + } + + const rect = ruler.getBoundingClientRect(); + const timelineWidth = BAR_WIDTH * arrangementLengthBars; + const x = Math.max(0, Math.min(clientX - rect.left, timelineWidth)); + + return clamp(Math.round(x / BAR_WIDTH), 0, arrangementLengthBars); +} + +function getClipStyle({ + instance, + trackCount, + trackIndex, +}: { + instance: ClipInstance; + trackCount: number; + trackIndex: number; +}): CSSProperties { + const boundedTrackCount = Math.max(1, trackCount); + const trackTopPercent = (trackIndex / boundedTrackCount) * 100; + const trackHeightPercent = 100 / boundedTrackCount; + + return { + height: `calc(${trackHeightPercent}% - ${CLIP_ROW_INSET * 2}px)`, + left: `${tickToPixels(instance.startTick)}px`, + top: `calc(${trackTopPercent}% + ${CLIP_ROW_INSET}px)`, + width: `${Math.max(32, tickToPixels(instance.lengthTicks))}px`, + }; +} + +function getDropPosition( + event: DragEvent, + timelineGrid: HTMLDivElement | null, + tracks: readonly ArrangementTrack[], +): { startTick: Tick; trackId: string } | null { + if (!timelineGrid || tracks.length === 0) { + return null; + } + + const rect = timelineGrid.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const trackHeight = rect.height / tracks.length; + const trackIndex = clamp(Math.floor(y / trackHeight), 0, tracks.length - 1); + + return { + startTick: pixelsToTicks(x), + trackId: tracks[trackIndex]?.id ?? tracks[0]!.id, + }; +} + +function handleClipBlockKeyDown({ + event, + instanceId, + onClipInstanceDelete, +}: { + event: KeyboardEvent; + instanceId: string; + onClipInstanceDelete: (instanceId: string) => void; +}) { + if (event.key !== "Delete" && event.key !== "Backspace") { + return; + } + + event.preventDefault(); + onClipInstanceDelete(instanceId); +} + +function hasArrangementDragPayload(event: DragEvent): boolean { + return ( + event.dataTransfer.types.includes(ARRANGEMENT_CLIP_DRAG_TYPE) || + event.dataTransfer.types.includes(ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE) + ); +} + +function tickToPixels(tick: Tick): number { + return (tick / TICKS_PER_4_4_BAR) * BAR_WIDTH; +} + +function pixelsToTicks(pixels: number): Tick { + return (Math.max(0, pixels) / BAR_WIDTH) * TICKS_PER_4_4_BAR; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/features/arrangement-view/MixerPanel.module.css b/src/features/arrangement-view/MixerPanel.module.css new file mode 100644 index 0000000..b4b2112 --- /dev/null +++ b/src/features/arrangement-view/MixerPanel.module.css @@ -0,0 +1,229 @@ +.mixerPanel { + background: var(--color-surface-container-lowest); + border-top: 1px solid var(--color-outline-variant); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 196px; + min-width: 0; + overflow: hidden; +} + +.header { + align-items: center; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + display: flex; + justify-content: space-between; + min-height: 38px; + padding: var(--space-gutter) var(--space-panel); +} + +.eyebrow, +.statusText, +.faderLabel, +.volumeValue, +.masterLabel, +.effectSlot { + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; +} + +.eyebrow { + color: var(--color-text-dim); + margin: 0; +} + +.statusText { + color: var(--color-text-dim); + margin: 0; +} + +.stripScroller { + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + padding: var(--space-gutter); + scrollbar-color: var(--color-primary-container) var(--color-surface-container-lowest); + scrollbar-width: thin; +} + +.stripScroller::-webkit-scrollbar { + height: 10px; +} + +.stripScroller::-webkit-scrollbar-track { + background: var(--color-surface-container-lowest); +} + +.stripScroller::-webkit-scrollbar-thumb { + background: var(--color-primary-container); + border: 2px solid var(--color-surface-container-lowest); + border-radius: var(--radius-md); +} + +.stripScroller::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} + +.stripRow { + display: flex; + gap: var(--space-gutter); + min-height: 100%; + width: max-content; +} + +.channelStrip { + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + box-shadow: var(--panel-inset-shadow); + display: grid; + gap: var(--space-gutter); + grid-template-rows: auto minmax(0, 1fr) auto auto; + min-height: 142px; + padding: var(--space-gutter); + width: 112px; +} + +.inactiveStrip { + opacity: 0.68; +} + +.masterStrip { + border-color: var(--color-primary); +} + +.channelHeader { + border-bottom: 1px solid var(--color-outline-variant); + display: grid; + gap: var(--space-unit); + min-width: 0; + padding-bottom: var(--space-unit); +} + +.trackName { + color: var(--color-text); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.controlsGrid { + align-items: stretch; + display: grid; + gap: var(--space-gutter); + grid-template-columns: 16px minmax(0, 1fr); + min-height: 0; +} + +.meter { + align-self: stretch; + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-xs); + box-shadow: var(--control-inset-shadow); + min-height: 72px; + overflow: hidden; + position: relative; +} + +.meterFill { + background: linear-gradient( + 0deg, + var(--color-primary), + var(--color-secondary-fixed) + ); + border-radius: var(--radius-xs); + bottom: 0; + left: 0; + position: absolute; + right: 0; +} + +.faderGroup { + align-items: center; + color: var(--color-text-muted); + display: grid; + gap: var(--space-unit); + grid-template-rows: auto minmax(0, 1fr) auto; + justify-items: center; + min-width: 0; +} + +.faderLabel, +.volumeValue { + color: var(--color-text-dim); +} + +.fader { + accent-color: var(--color-primary); + cursor: pointer; + direction: rtl; + height: 74px; + width: 26px; + writing-mode: vertical-lr; +} + +.toggleRow { + display: grid; + gap: var(--space-unit); + grid-template-columns: 1fr 1fr; +} + +.masterLabel { + align-items: center; + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-xs); + color: var(--color-text-dim); + display: flex; + justify-content: center; + min-height: 26px; +} + +.toggleButton, +.effectSlot { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-xs); + color: var(--color-text-muted); + cursor: pointer; + min-height: 26px; +} + +.toggleButton:hover, +.toggleButton:focus-visible, +.effectSlot:hover, +.effectSlot:focus-visible { + border-color: var(--color-outline); + color: var(--color-text); +} + +.muteActive { + background: var(--color-error); + border-color: var(--color-error); + color: var(--color-surface-container-lowest); +} + +.soloActive { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-on-primary); +} + +.effectSlot { + overflow: hidden; + padding: 0 var(--space-unit); + text-overflow: ellipsis; + white-space: nowrap; +} + +.effectSlot:disabled { + cursor: default; + opacity: 0.7; +} diff --git a/src/features/arrangement-view/MixerPanel.tsx b/src/features/arrangement-view/MixerPanel.tsx new file mode 100644 index 0000000..08e60b1 --- /dev/null +++ b/src/features/arrangement-view/MixerPanel.tsx @@ -0,0 +1,203 @@ +import type { CSSProperties } from "react"; + +import type { MixerLevelSnapshot } from "../../audio"; +import { + MIXER_MAX_VOLUME_DB, + MIXER_MIN_VOLUME_DB, + getTrackMixerState, + type MasterMixerState, + type TrackMixerState, +} from "../../model"; +import styles from "./MixerPanel.module.css"; + +export interface MixerTrack { + id: string; + name: string; + active: boolean; +} + +interface MixerPanelProps { + masterMixerState: MasterMixerState; + mixerLevels: MixerLevelSnapshot; + onMasterVolumeChange: (volumeDb: number) => void; + onTrackMuteToggle: (trackId: string) => void; + onTrackSoloToggle: (trackId: string) => void; + onTrackVolumeChange: (trackId: string, volumeDb: number) => void; + tracks: readonly MixerTrack[]; + trackMixerStates: readonly TrackMixerState[]; +} + +interface MixerChannel { + active: boolean; + id: string; + level: number; + name: string; + role: "track" | "master"; + state: MasterMixerState | TrackMixerState; +} + +const MASTER_CHANNEL_ID = "master"; +const FADER_STEP_DB = 1; + +export function MixerPanel({ + masterMixerState, + mixerLevels, + onMasterVolumeChange, + onTrackMuteToggle, + onTrackSoloToggle, + onTrackVolumeChange, + tracks, + trackMixerStates, +}: MixerPanelProps) { + const mixerChannels: MixerChannel[] = [ + ...tracks.map((track) => ({ + active: track.active, + id: track.id, + level: mixerLevels.trackLevels[track.id] ?? 0, + name: track.name, + role: "track" as const, + state: getTrackMixerState(trackMixerStates, track.id), + })), + { + active: true, + id: MASTER_CHANNEL_ID, + level: mixerLevels.masterLevel, + name: "Master", + role: "master" as const, + state: masterMixerState, + }, + ]; + + return ( +
+
+
+

MIXER

+
+

Live routing / runtime meters

+
+ +
+
+ {mixerChannels.map((channel) => { + const isTrackChannel = channel.role === "track"; + const trackState = isTrackChannel + ? (channel.state as TrackMixerState) + : null; + const volumeDb = channel.state.volumeDb; + + return ( +
+
+ {channel.name} +
+ +
+ + +
+ + {trackState ? ( +
+ + +
+ ) : ( +

MASTER OUT

+ )} + + +
+ ); + })} +
+
+
+ ); +} + +function LevelMeter({ + isMuted, + level, +}: { + isMuted: boolean; + level: number; +}) { + const meterStyle = { + height: `${Math.round((isMuted ? 0 : level) * 100)}%`, + } satisfies CSSProperties; + + return ( +
+ +
+ ); +} + +function formatVolumeDb(volumeDb: number): string { + if (volumeDb <= MIXER_MIN_VOLUME_DB) { + return "-60 dB"; + } + + return `${volumeDb > 0 ? "+" : ""}${volumeDb.toFixed(0)} dB`; +} diff --git a/src/features/arrangement-view/index.ts b/src/features/arrangement-view/index.ts new file mode 100644 index 0000000..dfaddcf --- /dev/null +++ b/src/features/arrangement-view/index.ts @@ -0,0 +1 @@ +export { ArrangementView } from "./ArrangementView"; diff --git a/src/features/audio-clip/AudioClipDetails.module.css b/src/features/audio-clip/AudioClipDetails.module.css new file mode 100644 index 0000000..70884da --- /dev/null +++ b/src/features/audio-clip/AudioClipDetails.module.css @@ -0,0 +1,140 @@ +.panel { + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + box-shadow: var(--panel-inset-shadow); + display: grid; + gap: var(--space-panel); + min-height: 0; + overflow: auto; + padding: var(--space-panel); +} + +.header { + border-bottom: 1px solid var(--color-outline-variant); + padding-bottom: var(--space-panel); +} + +.eyebrow { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + margin: 0 0 var(--space-unit); + text-transform: uppercase; +} + +.title { + color: var(--color-text); + font-size: var(--font-size-title); + line-height: var(--line-height-tight); + margin: 0; +} + +.detailsGrid { + display: grid; + gap: var(--space-gutter); + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +.previewControls { + display: flex; + flex-wrap: wrap; + gap: var(--space-gutter); +} + +.previewButton, +.previewButtonSecondary { + align-items: center; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-small); + font-weight: var(--font-weight-label); + min-height: 34px; + padding: 0 var(--space-panel); +} + +.previewButton { + background: var(--color-primary); + color: var(--color-on-primary); +} + +.previewButtonSecondary { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + color: var(--color-text-muted); +} + +.previewButton:hover, +.previewButton:focus-visible { + background: var(--color-primary-strong); +} + +.previewButtonSecondary:hover, +.previewButtonSecondary:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.previewButton:disabled, +.previewButtonSecondary:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.detailItem { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + display: grid; + gap: var(--space-unit); + min-width: 0; + padding: var(--space-gutter); +} + +.detailItem dt { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; +} + +.detailItem dd { + color: var(--color-text); + font-size: var(--font-size-small); + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.notice { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + display: grid; + font-size: var(--font-size-small); + gap: var(--space-unit); + padding: var(--space-panel); +} + +.notice strong { + color: var(--color-primary-strong); +} + +.error { + border: 1px solid var(--color-status-error); + border-radius: var(--radius-sm); + color: var(--color-status-error); + margin: 0; + padding: var(--space-gutter); +} + +@media (max-width: 920px) { + .detailsGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/features/audio-clip/AudioClipDetails.tsx b/src/features/audio-clip/AudioClipDetails.tsx new file mode 100644 index 0000000..994e2b9 --- /dev/null +++ b/src/features/audio-clip/AudioClipDetails.tsx @@ -0,0 +1,93 @@ +import type { AudioClip, SampleMeta } from "../../model"; +import styles from "./AudioClipDetails.module.css"; + +interface AudioClipDetailsProps { + clip: AudioClip; + errorMessage?: string | null; + isPreviewPlaying?: boolean; + onPreviewPlay: () => void; + onPreviewStop: () => void; + sampleMeta?: SampleMeta; +} + +export function AudioClipDetails({ + clip, + errorMessage = null, + isPreviewPlaying = false, + onPreviewPlay, + onPreviewStop, + sampleMeta, +}: AudioClipDetailsProps) { + return ( +
+
+

Imported WAV Clip

+

{clip.name}

+
+ +
+ + +
+ +
+
+
Source file
+
{clip.sourceFileName}
+
+
+
Duration
+
{formatDuration(clip.durationSeconds)}
+
+
+
File type
+
{clip.mimeType || sampleMeta?.source.mimeType || "audio/wav"}
+
+
+
Sample ID
+
{clip.sampleId}
+
+
+ +
+ Session-only import + + The decoded audio is available in the runtime cache for this browser + session. Refreshing the page will lose the imported file until IndexedDB + persistence is implemented. + +
+ + {errorMessage ?

{errorMessage}

: null} +
+ ); +} + +function formatDuration(durationSeconds: number): string { + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + return "Unknown"; + } + + const minutes = Math.floor(durationSeconds / 60); + const seconds = durationSeconds - minutes * 60; + + if (minutes > 0) { + return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`; + } + + return `${seconds.toFixed(2)} sec`; +} diff --git a/src/features/audio-clip/index.ts b/src/features/audio-clip/index.ts new file mode 100644 index 0000000..3e10ced --- /dev/null +++ b/src/features/audio-clip/index.ts @@ -0,0 +1 @@ +export { AudioClipDetails } from "./AudioClipDetails"; diff --git a/src/features/drum-sequencer/DrumSequencer.module.css b/src/features/drum-sequencer/DrumSequencer.module.css new file mode 100644 index 0000000..db90221 --- /dev/null +++ b/src/features/drum-sequencer/DrumSequencer.module.css @@ -0,0 +1,292 @@ +.stepSequencerPanel { + --drum-lane-width: 228px; + --drum-substep-gap: 3px; + --drum-substep-width: 18px; + + justify-self: start; + max-width: 100%; + width: fit-content; +} + +.panelActions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-gutter); +} + +.stepMeta { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); +} + +.subdivisionControl { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + display: flex; + overflow: hidden; +} + +.subdivisionButton { + background: transparent; + border: 0; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + min-height: 26px; + padding: 0 var(--space-gutter); + text-transform: uppercase; +} + +.subdivisionButton:hover, +.subdivisionButton:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.subdivisionButtonActive { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + +.sequencer { + display: grid; + gap: var(--space-gutter); + overflow-x: auto; + padding: var(--space-panel); + scrollbar-color: var(--color-primary-container) var(--color-surface-container-lowest); + scrollbar-width: thin; +} + +.sequencer::-webkit-scrollbar { + height: 10px; + width: 10px; +} + +.sequencer::-webkit-scrollbar-track { + background: var(--color-surface-container-lowest); + border-top: 1px solid var(--color-outline-variant); +} + +.sequencer::-webkit-scrollbar-thumb { + background: var(--color-primary-container); + border: 2px solid var(--color-surface-container-lowest); + border-radius: var(--radius-md); +} + +.sequencer::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} + +.beatHeader, +.lane { + display: grid; + gap: var(--space-gutter); + grid-template-columns: var(--drum-lane-width) minmax(0, 1fr); +} + +.beatHeader { + align-items: center; +} + +.stepNumbers { + display: flex; + gap: var(--space-gutter); + min-width: max-content; +} + +.stepNumber { + align-items: center; + color: var(--color-text-dim); + font-size: var(--font-size-label); + justify-content: center; + justify-items: center; +} + +.stepNumberPlayhead { + color: var(--color-playhead); + text-shadow: var(--playhead-shadow); +} + +.lane { + align-items: center; + cursor: grab; + position: relative; +} + +.laneDragging { + opacity: 0.55; +} + +.laneControls { + align-items: center; + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + display: flex; + gap: var(--space-gutter); + justify-content: space-between; + min-height: 38px; + min-width: 0; + padding: 0 var(--space-gutter); +} + +.samplePicker { + flex: 1; + min-width: 0; + position: relative; +} + +.laneLabelButton { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: block; + padding: 0; + text-align: left; + width: 100%; +} + +.laneLabelButton:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} + +.laneLabel { + color: var(--color-text); + display: block; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-label); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sampleMenu { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline); + border-radius: var(--radius-sm); + box-shadow: var(--panel-inset-shadow); + display: grid; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--space-unit); + position: fixed; + z-index: var(--z-index-popover); +} + +.sampleOption { + background: transparent; + border: 0; + border-radius: var(--radius-xs); + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + padding: var(--space-gutter); + text-align: left; +} + +.sampleOption:hover, +.sampleOption:focus-visible { + background: var(--color-surface-container); + color: var(--color-text); + outline: none; +} + +.sampleOptionSelected { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + +.knobGroup { + flex: 0 0 auto; + display: flex; + gap: var(--space-unit); +} + +.knob { + background: + radial-gradient(circle at 40% 30%, var(--color-surface-bright), transparent 42%), + var(--color-surface-container-highest); + border: 1px solid var(--color-outline-variant); + border-radius: 50%; + box-shadow: var(--control-inset-shadow); + height: 18px; + width: 18px; +} + +.steps { + display: flex; + gap: var(--space-gutter); + min-width: max-content; + padding-bottom: var(--space-unit); +} + +.primaryStepCell { + display: grid; + gap: var(--drum-substep-gap); + grid-template-columns: repeat( + var(--drum-step-subdivision), + var(--drum-substep-width) + ); +} + +.primaryStepCellAlternate .stepButton { + background: var(--color-surface-container-high); +} + +.stepButton { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-xs); + box-shadow: var(--control-inset-shadow); + cursor: pointer; + height: var(--step-size); + padding: 0; + width: var(--drum-substep-width); +} + +.stepButton:hover { + border-color: var(--color-outline); +} + +.stepGroupStart { + margin-left: var(--space-panel); +} + +.stepButtonActive { + background: var(--color-primary); + border-color: var(--color-primary-strong); + box-shadow: var(--step-active-shadow); +} + +.primaryStepCellAlternate .stepButtonActive { + background: var(--color-primary); +} + +.stepButtonPlayhead { + border-color: var(--color-playhead); + box-shadow: var(--playhead-shadow), var(--control-inset-shadow); +} + +.stepButtonActive.stepButtonPlayhead { + box-shadow: var(--playhead-shadow), var(--step-active-shadow); +} + +@media (max-width: 900px) { + .stepSequencerPanel { + --drum-lane-width: 188px; + --drum-substep-width: 16px; + } + + .beatHeader, + .lane { + grid-template-columns: var(--drum-lane-width) minmax(0, 1fr); + } +} diff --git a/src/features/drum-sequencer/DrumSequencer.tsx b/src/features/drum-sequencer/DrumSequencer.tsx new file mode 100644 index 0000000..7e3d51c --- /dev/null +++ b/src/features/drum-sequencer/DrumSequencer.tsx @@ -0,0 +1,412 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type DragEvent, + type MouseEvent, +} from "react"; +import { createPortal } from "react-dom"; + +import type { BundledSampleMeta } from "../../audio"; +import { Panel } from "../../components"; +import { + DRUM_STEP_COUNT, + DRUM_STEP_SUBDIVISIONS, + getDrumStepCount, + getHybridClipBarCount, + isDrumSubstepActive, + type DrumEvent, + type DrumLaneDefinition, + type DrumLaneId, + type DrumStepSubdivision, +} from "../../model"; +import { TICKS_PER_16_STEP, type Tick } from "../../utils"; +import styles from "./DrumSequencer.module.css"; + +interface DrumSequencerProps { + clipLengthTicks: Tick; + drumEvents: readonly DrumEvent[]; + drumLanes: readonly DrumLaneDefinition[]; + drumStepSubdivision: DrumStepSubdivision; + playheadTick: Tick; + samples: readonly BundledSampleMeta[]; + shouldShowPlayhead: boolean; + onLaneMove: (laneId: DrumLaneId, targetIndex: number) => void; + onLaneSampleChange: ( + laneId: DrumLaneId, + sample: BundledSampleMeta, + ) => void; + onStepToggle: ( + laneId: DrumLaneId, + stepIndex: number, + substepIndex: number, + ) => void; + onSubdivisionChange: (subdivision: DrumStepSubdivision) => void; +} + +interface SampleMenuPosition { + left: number; + maxHeight: number; + top: number; + width: number; +} + +const SAMPLE_MENU_GAP_PX = 4; +const SAMPLE_MENU_MARGIN_PX = 8; +const SAMPLE_MENU_MAX_HEIGHT_PX = 240; +const SAMPLE_MENU_MIN_HEIGHT_PX = 120; +const SAMPLE_MENU_MIN_WIDTH_PX = 240; + +export function DrumSequencer({ + clipLengthTicks, + drumEvents, + drumLanes, + drumStepSubdivision, + playheadTick, + samples, + shouldShowPlayhead, + onLaneMove, + onLaneSampleChange, + onStepToggle, + onSubdivisionChange, +}: DrumSequencerProps) { + const [openSampleLaneId, setOpenSampleLaneId] = useState( + null, + ); + const [sampleMenuPosition, setSampleMenuPosition] = + useState(null); + const [draggingLaneId, setDraggingLaneId] = useState(null); + const sampleButtonRefs = useRef(new Map()); + const openSampleLane = drumLanes.find((lane) => lane.id === openSampleLaneId); + const barCount = getHybridClipBarCount(clipLengthTicks); + const stepCount = getDrumStepCount(clipLengthTicks); + const playheadStepIndex = shouldShowPlayhead + ? getPlayheadStepIndex(playheadTick, clipLengthTicks) + : null; + const subdivisionStyle = { + "--drum-step-subdivision": drumStepSubdivision, + } as CSSProperties; + + useEffect(() => { + if (!openSampleLaneId) { + return; + } + + const laneId = openSampleLaneId; + + function updateMenuPosition() { + const button = sampleButtonRefs.current.get(laneId); + + if (!button) { + setSampleMenuPosition(null); + return; + } + + setSampleMenuPosition(getSampleMenuPosition(button)); + } + + updateMenuPosition(); + window.addEventListener("resize", updateMenuPosition); + window.addEventListener("scroll", updateMenuPosition, true); + + return () => { + window.removeEventListener("resize", updateMenuPosition); + window.removeEventListener("scroll", updateMenuPosition, true); + }; + }, [openSampleLaneId]); + + function handleDragStart( + event: DragEvent, + laneId: DrumLaneId, + ) { + setDraggingLaneId(laneId); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", laneId); + } + + function handleDrop(event: DragEvent, targetIndex: number) { + event.preventDefault(); + + const draggedLaneId = getDraggedLaneId( + event.dataTransfer.getData("text/plain"), + drumLanes, + ); + + if (draggedLaneId) { + onLaneMove(draggedLaneId, targetIndex); + } + + setDraggingLaneId(null); + } + + function handleSampleMenuToggle( + event: MouseEvent, + laneId: DrumLaneId, + ) { + if (openSampleLaneId === laneId) { + closeSampleMenu(); + return; + } + + setSampleMenuPosition(getSampleMenuPosition(event.currentTarget)); + setOpenSampleLaneId(laneId); + } + + function closeSampleMenu() { + setOpenSampleLaneId(null); + setSampleMenuPosition(null); + } + + return ( + <> + + + {barCount} BAR{barCount === 1 ? "" : "S"} / {stepCount} STEPS /{" "} + {drumStepSubdivision}X + +
+ {DRUM_STEP_SUBDIVISIONS.map((subdivision) => ( + + ))} +
+ + } + className={styles.stepSequencerPanel} + eyebrow="STEP SEQUENCER" + > +
+ + + {drumLanes.map((lane, laneIndex) => ( +
setDraggingLaneId(null)} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDragStart={(event) => handleDragStart(event, lane.id)} + onDrop={(event) => handleDrop(event, laneIndex)} + > +
+
+ +
+ +
+ +
+ {Array.from({ length: stepCount }, (_, stepIndex) => { + const isAlternateGroup = Math.floor(stepIndex / 4) % 2 === 1; + const isGroupStart = stepIndex > 0 && stepIndex % 4 === 0; + const isPlayheadStep = stepIndex === playheadStepIndex; + const barNumber = Math.floor(stepIndex / DRUM_STEP_COUNT) + 1; + const stepLabel = (stepIndex % DRUM_STEP_COUNT) + 1; + + return ( +
+ {Array.from( + { length: drumStepSubdivision }, + (_, substepIndex) => { + const isActive = isDrumSubstepActive({ + clipLengthTicks, + drumEvents, + laneId: lane.id, + stepIndex, + subdivision: drumStepSubdivision, + substepIndex, + }); + + return ( +
+ ); + })} +
+
+ ))} +
+
+ + {openSampleLane && sampleMenuPosition + ? createPortal( +
+ {samples.map((sample) => ( + + ))} +
, + document.body, + ) + : null} + + ); +} + +function getDraggedLaneId( + value: string, + drumLanes: readonly DrumLaneDefinition[], +): DrumLaneId | null { + return drumLanes.some((lane) => lane.id === value) + ? (value as DrumLaneId) + : null; +} + +function getPlayheadStepIndex( + playheadTick: Tick, + clipLengthTicks: Tick, +): number { + const loopTick = + ((playheadTick % clipLengthTicks) + clipLengthTicks) % clipLengthTicks; + + return Math.min( + Math.floor(loopTick / TICKS_PER_16_STEP), + getDrumStepCount(clipLengthTicks) - 1, + ); +} + +function getSampleMenuPosition(button: HTMLButtonElement): SampleMenuPosition { + const rect = button.getBoundingClientRect(); + const width = Math.min( + Math.max(rect.width, SAMPLE_MENU_MIN_WIDTH_PX), + window.innerWidth - SAMPLE_MENU_MARGIN_PX * 2, + ); + const left = Math.min( + Math.max(SAMPLE_MENU_MARGIN_PX, rect.left), + window.innerWidth - width - SAMPLE_MENU_MARGIN_PX, + ); + const belowTop = rect.bottom + SAMPLE_MENU_GAP_PX; + const belowSpace = window.innerHeight - belowTop - SAMPLE_MENU_MARGIN_PX; + const aboveSpace = rect.top - SAMPLE_MENU_GAP_PX - SAMPLE_MENU_MARGIN_PX; + const shouldOpenAbove = + belowSpace < SAMPLE_MENU_MIN_HEIGHT_PX && aboveSpace > belowSpace; + const availableHeight = shouldOpenAbove ? aboveSpace : belowSpace; + const maxHeight = Math.max( + SAMPLE_MENU_MIN_HEIGHT_PX, + Math.min(SAMPLE_MENU_MAX_HEIGHT_PX, availableHeight), + ); + const top = shouldOpenAbove + ? Math.max(SAMPLE_MENU_MARGIN_PX, rect.top - SAMPLE_MENU_GAP_PX - maxHeight) + : belowTop; + + return { + left, + maxHeight, + top, + width, + }; +} diff --git a/src/features/drum-sequencer/index.ts b/src/features/drum-sequencer/index.ts new file mode 100644 index 0000000..490825c --- /dev/null +++ b/src/features/drum-sequencer/index.ts @@ -0,0 +1 @@ +export { DrumSequencer } from "./DrumSequencer"; diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..a7bddb1 --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1,9 @@ +export { AudioClipDetails } from "./audio-clip"; +export { ArrangementView } from "./arrangement-view"; +export { DrumSequencer } from "./drum-sequencer"; +export { PianoRoll } from "./piano-roll"; +export { ProjectSidebar } from "./project-sidebar"; +export { SamplePlaybackPanel } from "./sample-playback"; +export { TransportBar } from "./transport"; +export type { InstrumentId } from "./project-sidebar"; +export type { TransportMode, TransportState } from "./transport"; diff --git a/src/features/piano-roll/PianoKeyboard.tsx b/src/features/piano-roll/PianoKeyboard.tsx new file mode 100644 index 0000000..ce6fd04 --- /dev/null +++ b/src/features/piano-roll/PianoKeyboard.tsx @@ -0,0 +1,37 @@ +import styles from "./PianoRoll.module.css"; + +export interface PianoKeyRow { + id: string; + label: string; + keyType: "white" | "black"; +} + +interface PianoKeyboardProps { + rows: PianoKeyRow[]; + scrollTop?: number; +} + +export function PianoKeyboard({ rows, scrollTop = 0 }: PianoKeyboardProps) { + return ( +
+ + ); +} diff --git a/src/features/piano-roll/PianoRoll.module.css b/src/features/piano-roll/PianoRoll.module.css new file mode 100644 index 0000000..ac9195c --- /dev/null +++ b/src/features/piano-roll/PianoRoll.module.css @@ -0,0 +1,209 @@ +.pianoRollPanel { + min-height: 0; +} + +.rollActions { + align-items: center; + color: var(--color-text-muted); + display: flex; + flex-wrap: wrap; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + gap: var(--space-gutter); + justify-content: flex-end; + text-transform: uppercase; +} + +.rollShell { + display: grid; + grid-template-rows: minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.editorBody { + display: grid; + grid-template-columns: var(--piano-key-width) minmax(0, 1fr); + min-height: 0; +} + +.keyboard { + background: var(--color-surface-container-lowest); + border-right: 1px solid var(--color-outline-variant); + display: grid; + grid-template-rows: var(--piano-ruler-height) minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} + +.keyboardHeader { + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + height: var(--piano-ruler-height); +} + +.keyboardRowsViewport { + min-height: 0; + overflow: hidden; +} + +.keyboardRows { + min-height: 0; + will-change: transform; +} + +.keyCell { + align-items: center; + border-bottom: 1px solid var(--color-outline-variant); + color: var(--color-key-label); + display: flex; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + height: var(--piano-row-height); + justify-content: flex-start; + padding-left: var(--space-gutter); +} + +.keyCellWhite { + background: var(--color-key-white); +} + +.keyCellBlack { + background: var(--color-key-black); +} + +.gridViewport { + display: grid; + grid-template-rows: var(--piano-ruler-height) minmax(0, 1fr); + min-height: 0; + overflow-x: auto; + overflow-y: auto; + scrollbar-color: var(--color-primary-container) var(--color-surface-container-lowest); + scrollbar-width: thin; +} + +.gridViewport::-webkit-scrollbar { + height: 10px; + width: 10px; +} + +.gridViewport::-webkit-scrollbar-track { + background: var(--color-surface-container-lowest); + border-left: 1px solid var(--color-outline-variant); +} + +.gridViewport::-webkit-scrollbar-thumb { + background: var(--color-primary-container); + border: 2px solid var(--color-surface-container-lowest); + border-radius: var(--radius-md); +} + +.gridViewport::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} + +.beatHeader { + align-items: center; + background: var(--color-surface-container); + border-bottom: 1px solid var(--color-outline-variant); + color: var(--color-text-dim); + display: grid; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + grid-template-columns: repeat(32, minmax(0, 1fr)); + min-width: 0; + position: sticky; + top: 0; + z-index: var(--z-index-editor-header); +} + +.beatMarker { + display: inline-flex; + justify-content: center; +} + +.beatMarkerOne { + grid-column: 1; +} + +.beatMarkerTwo { + grid-column: 9; +} + +.beatMarkerThree { + grid-column: 17; +} + +.beatMarkerFour { + grid-column: 25; +} + +.noteGrid { + background-color: var(--color-surface-container-lowest); + background-image: + linear-gradient( + 90deg, + var(--color-grid-line-strong) var(--piano-grid-strong-line-width), + transparent var(--piano-grid-strong-line-width) + ), + linear-gradient(var(--color-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px); + background-size: + var(--piano-beat-width) 100%, + 100% var(--piano-row-height), + var(--piano-step-width) 100%; + min-height: var(--piano-roll-grid-height); + min-width: 0; + position: relative; + touch-action: none; + user-select: none; +} + +.note { + align-items: center; + background: var(--color-note); + border: 1px solid var(--color-note-strong); + border-radius: var(--radius-md); + box-shadow: var(--note-shadow); + color: var(--color-note-text); + cursor: pointer; + display: flex; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + justify-content: flex-start; + overflow: hidden; + padding: 0 var(--space-unit); + position: absolute; + text-overflow: ellipsis; + white-space: nowrap; +} + +.noteActive { + border-color: var(--color-primary-strong); + box-shadow: var(--note-active-shadow); + cursor: grabbing; + z-index: 2; +} + +.noteDraft { + opacity: 0.72; + pointer-events: none; +} + +.playhead { + background: var(--color-playhead); + bottom: 0; + box-shadow: var(--playhead-shadow); + pointer-events: none; + position: absolute; + top: 0; + transform: translateX(-50%); + width: var(--playhead-width); + z-index: var(--z-index-editor-playhead); +} + +@media (max-width: 760px) { + .rollActions { + display: none; + } +} diff --git a/src/features/piano-roll/PianoRoll.tsx b/src/features/piano-roll/PianoRoll.tsx new file mode 100644 index 0000000..2e86343 --- /dev/null +++ b/src/features/piano-roll/PianoRoll.tsx @@ -0,0 +1,477 @@ +import { + type CSSProperties, + type MouseEvent, + type PointerEvent, + useRef, + useState, +} from "react"; + +import { Panel } from "../../components"; +import { + PIANO_ROLL_COLUMNS_PER_BAR, + PIANO_ROLL_PITCHES, + TICKS_PER_PIANO_ROLL_COLUMN, + getHybridClipBarCount, + getPianoRollColumnCount, + getPianoRollPitchByMidiNote, + type NoteEvent, +} from "../../model"; +import { type Tick } from "../../utils"; +import { PianoKeyboard } from "./PianoKeyboard"; +import styles from "./PianoRoll.module.css"; + +interface PianoRollProps { + clipLengthTicks: Tick; + instrumentName: string; + noteEvents: readonly NoteEvent[]; + playheadTick: Tick; + shouldShowPlayhead: boolean; + onNoteCreate: (note: { + durationTicks: Tick; + midiNote: number; + startTick: Tick; + }) => void; + onNoteDelete: (noteId: string) => void; + onNoteMove: (note: { + midiNote: number; + noteId: string; + startTick: Tick; + }) => void; +} + +interface GridPosition { + columnIndex: number; + rowIndex: number; +} + +interface DraftNote { + anchorColumnIndex: number; + currentColumnIndex: number; + pointerId: number; + rowIndex: number; +} + +interface MovingNote { + columnOffset: number; + currentColumnIndex: number; + currentRowIndex: number; + durationColumns: number; + noteId: string; + pointerId: number; +} + +interface NoteGeometry { + columnIndex: number; + durationColumns: number; + rowIndex: number; +} + +const pianoRows = PIANO_ROLL_PITCHES.map((pitch) => ({ + id: `midi-${pitch.midiNote}`, + keyType: pitch.keyType, + label: pitch.label, +})); + +const BEATS_PER_BAR = 4; +const PIANO_ROLL_COLUMNS_PER_BEAT = PIANO_ROLL_COLUMNS_PER_BAR / BEATS_PER_BAR; + +export function PianoRoll({ + clipLengthTicks, + instrumentName, + noteEvents, + playheadTick, + shouldShowPlayhead, + onNoteCreate, + onNoteDelete, + onNoteMove, +}: PianoRollProps) { + const gridRef = useRef(null); + const [draftNote, setDraftNote] = useState(null); + const [movingNote, setMovingNote] = useState(null); + const [gridScrollTop, setGridScrollTop] = useState(0); + const barCount = getHybridClipBarCount(clipLengthTicks); + const beatCount = barCount * BEATS_PER_BAR; + const columnCount = getPianoRollColumnCount(clipLengthTicks); + const beatMarkers = Array.from({ length: beatCount }, (_, beatIndex) => ({ + columnIndex: beatIndex * PIANO_ROLL_COLUMNS_PER_BEAT, + id: `beat-${beatIndex + 1}`, + label: String(beatIndex + 1), + })); + const timelineStyle = { + "--piano-beat-width": `calc(100% / ${beatCount})`, + "--piano-step-width": `calc(100% / ${columnCount})`, + gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`, + width: `calc(100% * ${barCount})`, + } as CSSProperties; + + function handleGridPointerDown(event: PointerEvent) { + if (event.button !== 0) { + return; + } + + const gridPosition = getGridPosition(event); + + if (!gridPosition) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setDraftNote({ + anchorColumnIndex: gridPosition.columnIndex, + currentColumnIndex: gridPosition.columnIndex, + pointerId: event.pointerId, + rowIndex: gridPosition.rowIndex, + }); + } + + function handleGridPointerMove(event: PointerEvent) { + if (!draftNote || draftNote.pointerId !== event.pointerId) { + return; + } + + const gridPosition = getGridPosition(event); + + if (!gridPosition) { + return; + } + + setDraftNote({ + ...draftNote, + currentColumnIndex: gridPosition.columnIndex, + }); + } + + function handleGridPointerUp(event: PointerEvent) { + if (!draftNote || draftNote.pointerId !== event.pointerId) { + return; + } + + const draftGeometry = getDraftNoteGeometry(draftNote); + const pitch = PIANO_ROLL_PITCHES[draftGeometry.rowIndex]; + + if (pitch) { + onNoteCreate({ + durationTicks: + draftGeometry.durationColumns * TICKS_PER_PIANO_ROLL_COLUMN, + midiNote: pitch.midiNote, + startTick: draftGeometry.columnIndex * TICKS_PER_PIANO_ROLL_COLUMN, + }); + } + + event.currentTarget.releasePointerCapture(event.pointerId); + setDraftNote(null); + } + + function handleGridPointerCancel(event: PointerEvent) { + if (draftNote?.pointerId === event.pointerId) { + setDraftNote(null); + } + } + + function handleNotePointerDown( + event: PointerEvent, + note: NoteEvent, + ) { + if (event.button !== 0) { + return; + } + + const gridPosition = getGridPosition(event); + const noteGeometry = getNoteGeometry(note, columnCount); + + if (!gridPosition || !noteGeometry) { + return; + } + + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setMovingNote({ + columnOffset: Math.max( + gridPosition.columnIndex - noteGeometry.columnIndex, + 0, + ), + currentColumnIndex: noteGeometry.columnIndex, + currentRowIndex: noteGeometry.rowIndex, + durationColumns: noteGeometry.durationColumns, + noteId: note.id, + pointerId: event.pointerId, + }); + } + + function handleNotePointerMove(event: PointerEvent) { + if (!movingNote || movingNote.pointerId !== event.pointerId) { + return; + } + + const gridPosition = getGridPosition(event); + + if (!gridPosition) { + return; + } + + const maxColumnIndex = columnCount - movingNote.durationColumns; + setMovingNote({ + ...movingNote, + currentColumnIndex: clamp( + gridPosition.columnIndex - movingNote.columnOffset, + 0, + maxColumnIndex, + ), + currentRowIndex: gridPosition.rowIndex, + }); + } + + function handleNotePointerUp(event: PointerEvent) { + if (!movingNote || movingNote.pointerId !== event.pointerId) { + return; + } + + const pitch = PIANO_ROLL_PITCHES[movingNote.currentRowIndex]; + + if (pitch) { + onNoteMove({ + midiNote: pitch.midiNote, + noteId: movingNote.noteId, + startTick: + movingNote.currentColumnIndex * TICKS_PER_PIANO_ROLL_COLUMN, + }); + } + + event.currentTarget.releasePointerCapture(event.pointerId); + setMovingNote(null); + } + + function handleNotePointerCancel(event: PointerEvent) { + if (movingNote?.pointerId === event.pointerId) { + setMovingNote(null); + } + } + + function handleNoteContextMenu( + event: MouseEvent, + noteId: string, + ) { + event.preventDefault(); + onNoteDelete(noteId); + } + + function getGridPosition( + event: PointerEvent, + ): GridPosition | null { + const gridElement = gridRef.current; + + if (!gridElement) { + return null; + } + + const rect = gridElement.getBoundingClientRect(); + const x = clamp(event.clientX - rect.left, 0, rect.width - 1); + const y = clamp(event.clientY - rect.top, 0, rect.height - 1); + const rowHeight = rect.height / PIANO_ROLL_PITCHES.length; + + return { + columnIndex: clamp( + Math.floor((x / rect.width) * columnCount), + 0, + columnCount - 1, + ), + rowIndex: clamp( + Math.floor(y / rowHeight), + 0, + PIANO_ROLL_PITCHES.length - 1, + ), + }; + } + + const gridHeight = `calc(var(--piano-row-height) * ${PIANO_ROLL_PITCHES.length})`; + const movingNoteId = movingNote?.noteId ?? null; + + return ( + + Grid: 1/32 + {barCount} bar{barCount === 1 ? "" : "s"} + Tool: Draw +
+ } + className={styles.pianoRollPanel} + eyebrow="PIANO ROLL" + title={instrumentName} + > +
+
+ + +
setGridScrollTop(event.currentTarget.scrollTop)} + > + + +
event.preventDefault()} + onPointerCancel={handleGridPointerCancel} + onPointerDown={handleGridPointerDown} + onPointerMove={handleGridPointerMove} + onPointerUp={handleGridPointerUp} + ref={gridRef} + style={{ ...timelineStyle, height: gridHeight }} + > + {noteEvents.map((note) => { + const noteGeometry = + note.id === movingNoteId && movingNote + ? { + columnIndex: movingNote.currentColumnIndex, + durationColumns: movingNote.durationColumns, + rowIndex: movingNote.currentRowIndex, + } + : getNoteGeometry(note, columnCount); + + if (!noteGeometry) { + return null; + } + + const pitch = getPianoRollPitchByMidiNote(note.midiNote); + const noteLabel = pitch?.label ?? `MIDI ${note.midiNote}`; + + return ( + + ); + })} + + {draftNote ? ( +
+ {PIANO_ROLL_PITCHES[draftNote.rowIndex]?.label} +
+ ) : null} + + {shouldShowPlayhead ? ( + +
+
+
+ + ); +} + +function getDraftNoteGeometry(draftNote: DraftNote): NoteGeometry { + const columnIndex = Math.min( + draftNote.anchorColumnIndex, + draftNote.currentColumnIndex, + ); + const durationColumns = + Math.abs(draftNote.currentColumnIndex - draftNote.anchorColumnIndex) + 1; + + return { + columnIndex, + durationColumns, + rowIndex: draftNote.rowIndex, + }; +} + +function getNoteGeometry( + note: NoteEvent, + columnCount: number, +): NoteGeometry | null { + const rowIndex = PIANO_ROLL_PITCHES.findIndex( + (pitch) => pitch.midiNote === note.midiNote, + ); + + if (rowIndex < 0) { + return null; + } + + return { + columnIndex: clamp( + Math.round(note.startTick / TICKS_PER_PIANO_ROLL_COLUMN), + 0, + columnCount - 1, + ), + durationColumns: clamp( + Math.round(note.durationTicks / TICKS_PER_PIANO_ROLL_COLUMN), + 1, + columnCount, + ), + rowIndex, + }; +} + +function getNoteStyle({ + columnIndex, + durationColumns, + rowIndex, +}: NoteGeometry, columnCount: number): CSSProperties { + return { + height: "var(--piano-row-height)", + left: `${(columnIndex / columnCount) * 100}%`, + top: `calc(var(--piano-row-height) * ${rowIndex})`, + width: `${(durationColumns / columnCount) * 100}%`, + }; +} + +function getPlayheadStyle({ + clipLengthTicks, + playheadTick, +}: { + clipLengthTicks: Tick; + playheadTick: Tick; +}): CSSProperties { + if (clipLengthTicks <= 0) { + return { left: "0%" }; + } + + const loopTick = + ((playheadTick % clipLengthTicks) + clipLengthTicks) % clipLengthTicks; + + return { + left: `${(loopTick / clipLengthTicks) * 100}%`, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/src/features/piano-roll/index.ts b/src/features/piano-roll/index.ts new file mode 100644 index 0000000..eda7ab8 --- /dev/null +++ b/src/features/piano-roll/index.ts @@ -0,0 +1 @@ +export { PianoRoll } from "./PianoRoll"; diff --git a/src/features/project-sidebar/ProjectSidebar.module.css b/src/features/project-sidebar/ProjectSidebar.module.css new file mode 100644 index 0000000..4673c9a --- /dev/null +++ b/src/features/project-sidebar/ProjectSidebar.module.css @@ -0,0 +1,339 @@ +.sidebar { + background: var(--color-surface-container-lowest); + border-right: 1px solid var(--color-outline-variant); + display: flex; + flex-direction: column; + min-height: 0; + width: 100%; +} + +.projectHeader { + border-bottom: 1px solid var(--color-outline-variant); + padding: var(--space-panel); +} + +.sectionLabel { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + letter-spacing: var(--letter-spacing-label); + margin: 0; + text-transform: uppercase; +} + +.projectName { + color: var(--color-text); + font-size: var(--font-size-title); + line-height: var(--line-height-tight); + margin: 0; +} + +.clipBrowser { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-panel); +} + +.sectionHeader { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: var(--space-gutter); +} + +.clipAddControl { + position: relative; +} + +.clipAddMenu { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + box-shadow: var(--panel-inset-shadow); + display: grid; + gap: var(--space-unit); + min-width: 168px; + padding: var(--space-unit); + position: absolute; + right: 0; + top: calc(100% + var(--space-unit)); + z-index: var(--z-index-popover); +} + +.clipAddMenuItem { + align-items: center; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + display: flex; + gap: var(--space-gutter); + min-height: 32px; + padding: 0 var(--space-gutter); + text-align: left; + width: 100%; +} + +.clipAddMenuItem:hover, +.clipAddMenuItem:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.hiddenFileInput { + display: none; +} + +.importError { + border: 1px solid var(--color-status-error); + border-radius: var(--radius-sm); + color: var(--color-status-error); + font-size: var(--font-size-caption); + margin: 0 0 var(--space-gutter); + padding: var(--space-gutter); +} + +.clipGroup { + margin-bottom: var(--space-gutter); +} + +.clipHeader { + align-items: center; + background: var(--color-surface-container); + border: 1px solid transparent; + border-radius: var(--radius-sm); + display: flex; + gap: var(--space-unit); + min-height: 34px; + padding: 0 var(--space-unit); +} + +.clipHeader:hover { + background: var(--color-surface-container-high); +} + +.clipHeaderActive { + border-color: var(--color-outline-variant); + color: var(--color-primary-strong); +} + +.clipExpandButton, +.clipSelectButton, +.instrumentButton, +.instrumentPickerOption, +.footerButton { + align-items: center; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + display: flex; + gap: var(--space-gutter); + min-height: 34px; + padding: 0 var(--space-gutter); + text-align: left; + width: 100%; +} + +.clipExpandButton { + flex-shrink: 0; + justify-content: center; + padding: 0; + width: 28px; +} + +.clipSelectButton { + flex: 1; + color: var(--color-primary-strong); + font-weight: var(--font-weight-label); + min-width: 0; +} + +.clipSelectButton span, +.instrumentButton span, +.instrumentPickerOption span, +.footerButton span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clipActions { + display: flex; + flex-shrink: 0; + gap: var(--space-unit); +} + +.iconButton, +.instrumentRemoveButton { + align-items: center; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-dim); + cursor: pointer; + display: inline-flex; + height: 28px; + justify-content: center; + width: 28px; +} + +.iconButton:hover, +.instrumentRemoveButton:hover, +.iconButton:focus-visible, +.instrumentRemoveButton:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-primary-strong); +} + +.iconButton:disabled, +.instrumentRemoveButton:disabled { + color: var(--color-text-dim); + cursor: not-allowed; + opacity: 0.35; +} + +.iconButton:disabled:hover, +.instrumentRemoveButton:disabled:hover { + background: transparent; + color: var(--color-text-dim); +} + +.renameForm { + flex: 1; + min-width: 0; +} + +.renameInput { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline); + border-radius: var(--radius-sm); + color: var(--color-text); + font: inherit; + min-height: 28px; + padding: 0 var(--space-gutter); + width: 100%; +} + +.clipSelectButton:hover, +.instrumentButton:hover, +.instrumentPickerOption:hover, +.footerButton:hover { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.clipHeader .clipExpandButton:hover, +.clipHeader .clipSelectButton:hover, +.clipHeader .iconButton:hover, +.clipHeader .clipExpandButton:focus-visible, +.clipHeader .clipSelectButton:focus-visible, +.clipHeader .iconButton:focus-visible, +.instrumentRow .instrumentButton:hover, +.instrumentRow .instrumentRemoveButton:hover, +.instrumentRow .instrumentButton:focus-visible, +.instrumentRow .instrumentRemoveButton:focus-visible { + background: transparent; +} + +.instrumentPicker { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + box-shadow: var(--panel-inset-shadow); + margin: var(--space-gutter) 0 0 var(--space-panel); + max-height: 144px; + overflow-y: auto; + padding: var(--space-unit); +} + +.instrumentPickerOption { + min-height: 30px; +} + +.emptyPicker { + color: var(--color-text-dim); + font-size: var(--font-size-caption); + margin: 0; + padding: var(--space-gutter); +} + +.instrumentList { + border-left: 1px solid var(--color-outline-variant); + margin: var(--space-gutter) 0 0 var(--space-panel); + padding-left: var(--space-gutter); +} + +.instrumentRow { + align-items: center; + border-radius: var(--radius-sm); + display: flex; + gap: var(--space-unit); + margin-bottom: var(--space-unit); +} + +.instrumentRow:hover { + background: var(--color-surface-container-high); +} + +.instrumentRowActive { + background: var(--color-primary-container); +} + +.instrumentButton { + flex: 1; + margin-bottom: var(--space-unit); + min-width: 0; +} + +.instrumentRow .instrumentButton { + margin-bottom: 0; +} + +.instrumentRemoveButton { + flex-shrink: 0; + height: 30px; + width: 30px; +} + +.instrumentRow .instrumentButtonActive { + background: transparent; + color: var(--color-on-primary-container); +} + +.instrumentRowActive .instrumentRemoveButton { + color: var(--color-on-primary-container); +} + +.instrumentButtonActive { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + +.sidebarFooter { + border-top: 1px solid var(--color-outline-variant); + display: grid; + gap: var(--space-unit); + padding: var(--space-panel); +} + +.footerButton:disabled { + color: var(--color-text-dim); + cursor: wait; + opacity: 0.6; +} + +.footerButton:disabled:hover { + background: transparent; + color: var(--color-text-dim); +} + +.exportError { + border: 1px solid var(--color-status-error); + border-radius: var(--radius-sm); + color: var(--color-status-error); + font-size: var(--font-size-caption); + margin: var(--space-unit) 0 0; + padding: var(--space-gutter); +} diff --git a/src/features/project-sidebar/ProjectSidebar.tsx b/src/features/project-sidebar/ProjectSidebar.tsx new file mode 100644 index 0000000..bb33492 --- /dev/null +++ b/src/features/project-sidebar/ProjectSidebar.tsx @@ -0,0 +1,447 @@ +import { + useRef, + useState, + type ChangeEvent, + type DragEvent, + type FormEvent, + type KeyboardEvent, +} from "react"; + +import { Icon } from "../../components"; +import { + ARRANGEMENT_CLIP_DRAG_TYPE, + type Clip, + PITCHED_INSTRUMENTS, + type PitchedInstrumentId, + isAudioClip, + isHybridClip, +} from "../../model"; +import styles from "./ProjectSidebar.module.css"; + +export type InstrumentId = "audio" | "drums" | PitchedInstrumentId; + +interface ProjectSidebarProps { + arrangementExportError?: string | null; + clips: readonly Clip[]; + clipImportError?: string | null; + isArrangementExporting?: boolean; + isClipImporting?: boolean; + projectName: string; + selectedClipId: string; + selectedInstrumentId: InstrumentId; + onArrangementExport: () => void; + onClipAdd: () => void; + onClipDelete: (clipId: string) => void; + onClipImport: (file: File) => void; + onClipRename: (clipId: string, name: string) => void; + onClipSelect: (clipId: string) => void; + onInstrumentAdd: (clipId: string, instrumentId: PitchedInstrumentId) => void; + onInstrumentRemove: (clipId: string, instrumentId: PitchedInstrumentId) => void; + onInstrumentSelect: (clipId: string, instrumentId: InstrumentId) => void; +} + +export function ProjectSidebar({ + arrangementExportError = null, + clips, + clipImportError = null, + isArrangementExporting = false, + isClipImporting = false, + projectName, + selectedClipId, + selectedInstrumentId, + onArrangementExport, + onClipAdd, + onClipDelete, + onClipImport, + onClipRename, + onClipSelect, + onInstrumentAdd, + onInstrumentRemove, + onInstrumentSelect, +}: ProjectSidebarProps) { + const fileInputRef = useRef(null); + const [addingInstrumentClipId, setAddingInstrumentClipId] = + useState(null); + const [isClipAddMenuOpen, setIsClipAddMenuOpen] = useState(false); + const [collapsedClipIds, setCollapsedClipIds] = useState>( + () => new Set(), + ); + const [renamingClipId, setRenamingClipId] = useState(null); + const [draftClipName, setDraftClipName] = useState(""); + const shouldIgnoreRenameBlurRef = useRef(false); + + function handleBuildClipClick() { + setIsClipAddMenuOpen(false); + onClipAdd(); + } + + function handleImportFileClick() { + setIsClipAddMenuOpen(false); + fileInputRef.current?.click(); + } + + function handleImportFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + + event.target.value = ""; + + if (!file) { + return; + } + + onClipImport(file); + } + + function beginClipRename(clip: Clip) { + shouldIgnoreRenameBlurRef.current = false; + setAddingInstrumentClipId(null); + setRenamingClipId(clip.id); + setDraftClipName(clip.name); + } + + function cancelClipRename() { + shouldIgnoreRenameBlurRef.current = true; + setRenamingClipId(null); + setDraftClipName(""); + } + + function commitClipRename(clipId: string) { + if (renamingClipId !== clipId) { + return; + } + + onClipRename(clipId, draftClipName); + shouldIgnoreRenameBlurRef.current = true; + cancelClipRename(); + } + + function handleRenameBlur(clipId: string) { + if (shouldIgnoreRenameBlurRef.current) { + shouldIgnoreRenameBlurRef.current = false; + return; + } + + commitClipRename(clipId); + } + + function handleRenameSubmit(event: FormEvent, clipId: string) { + event.preventDefault(); + commitClipRename(clipId); + } + + function handleRenameKeyDown( + event: KeyboardEvent, + clipId: string, + ) { + if (event.key === "Escape") { + event.preventDefault(); + cancelClipRename(); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + commitClipRename(clipId); + } + } + + function toggleInstrumentPicker(clipId: string) { + setRenamingClipId(null); + setCollapsedClipIds((currentClipIds) => { + const nextClipIds = new Set(currentClipIds); + + nextClipIds.delete(clipId); + return nextClipIds; + }); + setAddingInstrumentClipId((currentClipId) => + currentClipId === clipId ? null : clipId, + ); + } + + function toggleClipExpanded(clipId: string) { + setAddingInstrumentClipId(null); + setCollapsedClipIds((currentClipIds) => { + const nextClipIds = new Set(currentClipIds); + + if (nextClipIds.has(clipId)) { + nextClipIds.delete(clipId); + } else { + nextClipIds.add(clipId); + } + + return nextClipIds; + }); + } + + function handleClipDragStart( + event: DragEvent, + clipId: string, + ) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData(ARRANGEMENT_CLIP_DRAG_TYPE, clipId); + } + + return ( + + ); +} diff --git a/src/features/project-sidebar/index.ts b/src/features/project-sidebar/index.ts new file mode 100644 index 0000000..0e5b685 --- /dev/null +++ b/src/features/project-sidebar/index.ts @@ -0,0 +1,2 @@ +export { ProjectSidebar } from "./ProjectSidebar"; +export type { InstrumentId } from "./ProjectSidebar"; diff --git a/src/features/sample-playback/SamplePlaybackPanel.module.css b/src/features/sample-playback/SamplePlaybackPanel.module.css new file mode 100644 index 0000000..e473f70 --- /dev/null +++ b/src/features/sample-playback/SamplePlaybackPanel.module.css @@ -0,0 +1,131 @@ +.panel { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-panel); + padding: var(--space-panel-padding); +} + +.header { + align-items: flex-start; + display: flex; + gap: var(--space-content-gap); + justify-content: space-between; +} + +.panelLabel { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-label); + margin: 0 0 var(--space-label-gap); + text-transform: uppercase; +} + +.panelTitle { + font-size: var(--font-size-heading); + line-height: var(--line-height-tight); + margin: 0; +} + +.loadButton, +.sampleButton { + background: var(--color-control-background); + border: 1px solid var(--color-control-border); + border-radius: var(--radius-control); + color: var(--color-text-primary); + cursor: pointer; + font: inherit; +} + +.loadButton { + min-height: 40px; + padding: 0 var(--space-control-x); +} + +.loadButton:disabled, +.sampleButton:disabled { + color: var(--color-text-disabled); + cursor: not-allowed; +} + +.loadButton:focus-visible, +.sampleButton:focus-visible { + border-color: var(--color-control-accent); + outline: 2px solid var(--color-control-accent); + outline-offset: 2px; +} + +.statusGrid { + display: grid; + gap: var(--space-control-gap); + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: var(--space-content-gap) 0 0; +} + +.statusGrid div { + background: var(--color-control-background); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-control); + padding: var(--space-control-gap); +} + +.statusGrid dt { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + margin: 0 0 var(--space-label-gap); +} + +.statusGrid dd { + font-weight: var(--font-weight-label); + margin: 0; +} + +.message, +.errorMessage { + color: var(--color-text-secondary); + margin: var(--space-content-gap) 0 0; +} + +.errorMessage { + color: var(--color-status-error); +} + +.sampleGrid { + display: grid; + gap: var(--space-control-gap); + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: var(--space-content-gap); +} + +.sampleButton { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: var(--space-label-gap); + min-height: 72px; + padding: var(--space-control-gap); + text-align: left; +} + +.sampleButton:hover:not(:disabled) { + border-color: var(--color-control-accent); +} + +.sampleName { + font-weight: var(--font-weight-label); +} + +.sampleState { + color: var(--color-text-muted); + font-size: var(--font-size-caption); +} + +@media (max-width: 640px) { + .header { + flex-direction: column; + } + + .statusGrid, + .sampleGrid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/features/sample-playback/SamplePlaybackPanel.tsx b/src/features/sample-playback/SamplePlaybackPanel.tsx new file mode 100644 index 0000000..e23da9f --- /dev/null +++ b/src/features/sample-playback/SamplePlaybackPanel.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; + +import { BUNDLED_DRUM_SAMPLES, createAudioEngine } from "../../audio"; +import type { AudioEngineSnapshot, SampleId } from "../../audio"; +import styles from "./SamplePlaybackPanel.module.css"; + +const audioEngine = createAudioEngine(); + +type PlaybackStatus = "idle" | "loading" | "ready" | "playing" | "error"; + +export function SamplePlaybackPanel() { + const [snapshot, setSnapshot] = useState( + audioEngine.getSnapshot(), + ); + const [status, setStatus] = useState("idle"); + const [message, setMessage] = useState( + "Load samples, then trigger one-shots from the audio engine.", + ); + const [activeSampleId, setActiveSampleId] = useState(null); + + const isLoading = status === "loading"; + + async function handleLoadSamples() { + setStatus("loading"); + setMessage("Creating AudioContext and decoding bundled drum samples."); + + try { + const nextSnapshot = await audioEngine.loadAllSamples(); + setSnapshot(nextSnapshot); + setStatus("ready"); + setMessage("Samples decoded into the audio engine runtime cache."); + } catch (error) { + setStatus("error"); + setMessage(getErrorMessage(error)); + } + } + + async function handlePlaySample(sampleId: SampleId, sampleName: string) { + setStatus("playing"); + setActiveSampleId(sampleId); + setMessage(`Scheduling ${sampleName} one-shot playback.`); + + try { + await audioEngine.playSample(sampleId); + setSnapshot(audioEngine.getSnapshot()); + setStatus("ready"); + setMessage(`${sampleName} was scheduled with a fresh source node.`); + } catch (error) { + setStatus("error"); + setMessage(getErrorMessage(error)); + } finally { + setActiveSampleId(null); + } + } + + return ( +
+
+
+

Audio engine

+

Sample playback

+
+ +
+ +
+
+
AudioContext
+
{snapshot.contextState}
+
+
+
Decoded samples
+
+ {snapshot.loadedSampleIds.length} / {BUNDLED_DRUM_SAMPLES.length} +
+
+
+ +

+ {message} +

+ +
+ {BUNDLED_DRUM_SAMPLES.map((sample) => ( + + ))} +
+
+ ); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Audio playback failed."; +} diff --git a/src/features/sample-playback/index.ts b/src/features/sample-playback/index.ts new file mode 100644 index 0000000..c342322 --- /dev/null +++ b/src/features/sample-playback/index.ts @@ -0,0 +1 @@ +export { SamplePlaybackPanel } from "./SamplePlaybackPanel"; diff --git a/src/features/transport/TransportBar.module.css b/src/features/transport/TransportBar.module.css new file mode 100644 index 0000000..4c3eb1f --- /dev/null +++ b/src/features/transport/TransportBar.module.css @@ -0,0 +1,280 @@ +.transportBar { + align-items: center; + background: var(--color-surface-container-lowest); + border-bottom: 1px solid var(--color-outline-variant); + box-shadow: var(--transport-shadow); + display: grid; + gap: var(--space-panel); + grid-template-columns: minmax(180px, 1fr) auto minmax(220px, auto) auto minmax(160px, 1fr); + height: var(--top-bar-height); + padding: 0 var(--space-panel); +} + +.brandGroup, +.transportGroup, +.bpmControl, +.modeToggle, +.rightStatus { + align-items: center; + display: flex; +} + +.brandGroup { + gap: var(--space-gutter); + min-width: 0; +} + +.logoMark { + align-items: center; + background: var(--color-primary); + border-radius: var(--radius-sm); + color: var(--color-on-primary); + display: flex; + font-weight: var(--font-weight-title); + height: 28px; + justify-content: center; + width: 28px; +} + +.appLabel { + color: var(--color-text); + font-size: var(--font-size-body); + font-weight: var(--font-weight-title); + line-height: var(--line-height-tight); + margin: 0; +} + +.statusText { + color: var(--color-text-dim); + font-size: var(--font-size-label); + margin: 0; + text-transform: uppercase; +} + +.transportGroup { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + box-shadow: var(--control-inset-shadow); + gap: var(--space-unit); + padding: var(--space-unit); +} + +.iconButton { + align-items: center; + background: var(--color-surface-container-high); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + display: flex; + height: 32px; + justify-content: center; + width: 36px; +} + +.iconButton:hover { + color: var(--color-primary-strong); +} + +.iconButtonActive { + background: var(--color-primary); + color: var(--color-on-primary); +} + +.recordButton { + color: var(--color-error); +} + +.bpmControl { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + gap: var(--space-gutter); + min-width: 220px; + padding: var(--space-unit) var(--space-gutter); +} + +.bpmValue { + color: var(--color-primary-strong); + font-size: var(--font-size-title); + font-weight: var(--font-weight-title); + min-width: 40px; + text-align: right; +} + +.bpmLabel { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); +} + +.bpmSlider { + accent-color: var(--color-primary); + min-width: 110px; +} + +.modeToggle { + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + overflow: hidden; +} + +.modeButton { + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-label); + min-height: 32px; + padding: 0 var(--space-panel); +} + +.modeButtonActive { + background: var(--color-secondary); + color: var(--color-on-secondary); +} + +.rightStatus { + color: var(--color-text-dim); + gap: var(--space-gutter); + justify-content: flex-end; + min-width: 0; +} + +.projectMenuRoot { + min-width: 0; + position: relative; +} + +.projectButton { + align-items: center; + background: var(--color-surface-container); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + display: flex; + gap: var(--space-unit); + max-width: 220px; + min-height: 28px; + padding: 0 var(--space-gutter); +} + +.projectButton:hover { + border-color: var(--color-outline); + color: var(--color-primary-strong); +} + +.projectButton:disabled { + cursor: wait; + opacity: 0.65; +} + +.projectName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.projectMenu { + background: var(--color-surface-container-low); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-md); + box-shadow: var(--panel-inset-shadow); + color: var(--color-text); + min-width: 240px; + padding: var(--space-gutter); + position: absolute; + right: 0; + top: calc(100% + var(--space-gutter)); + z-index: var(--z-index-popover); +} + +.projectMenuLabel { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + margin: 0 0 var(--space-gutter); + text-transform: uppercase; +} + +.projectMenuList { + display: flex; + flex-direction: column; + gap: var(--space-unit); + max-height: 180px; + overflow-y: auto; + padding-bottom: var(--space-gutter); +} + +.projectMenuItem, +.projectMenuAction { + align-items: center; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + display: flex; + gap: var(--space-gutter); + min-height: 30px; + padding: 0 var(--space-gutter); + text-align: left; + width: 100%; +} + +.projectMenuItem:hover, +.projectMenuAction:hover { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.projectMenuItemActive { + background: var(--color-primary-container); + color: var(--color-on-primary-container); + font-weight: var(--font-weight-label); +} + +.projectMenuActions { + border-top: 1px solid var(--color-outline-variant); + display: flex; + flex-direction: column; + gap: var(--space-unit); + padding-top: var(--space-gutter); +} + +.projectMenuDangerAction { + color: var(--color-error); +} + +.projectMenuItem:disabled, +.projectMenuAction:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.saveStatus { + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-xs); + color: var(--color-text-muted); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + padding: var(--space-unit) var(--space-gutter); + text-transform: uppercase; +} + +.saveStatusError { + border-color: var(--color-error); + color: var(--color-error); +} + +@media (max-width: 920px) { + .transportBar { + grid-template-columns: minmax(140px, 1fr) auto auto; + } + + .bpmControl, + .rightStatus { + display: none; + } +} diff --git a/src/features/transport/TransportBar.tsx b/src/features/transport/TransportBar.tsx new file mode 100644 index 0000000..e4e6955 --- /dev/null +++ b/src/features/transport/TransportBar.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; + +import { Icon } from "../../components"; +import { MAX_TEMPO_BPM, MIN_TEMPO_BPM } from "../../utils"; +import styles from "./TransportBar.module.css"; + +export type TransportState = "paused" | "playing" | "stopped"; +export type TransportMode = "pattern" | "song"; + +interface ProjectMenuProject { + id: string; + name: string; + updatedAt: number; +} + +interface TransportBarProps { + activeProjectId: string; + bpm: number; + isProjectOperationPending?: boolean; + mode: TransportMode; + persistenceStatusLabel?: string; + persistenceStatusTitle?: string; + persistenceStatusTone?: "default" | "error"; + projectName: string; + projects: readonly ProjectMenuProject[]; + transportState: TransportState; + onBpmChange: (bpm: number) => void; + onModeChange: (mode: TransportMode) => void; + onProjectCreate: () => void; + onProjectDelete: () => void; + onProjectRename: () => void; + onProjectSelect: (projectId: string) => void; + onTransportStateChange: (state: TransportState) => void; +} + +export function TransportBar({ + activeProjectId, + bpm, + isProjectOperationPending = false, + mode, + persistenceStatusLabel = "Saved", + persistenceStatusTitle, + persistenceStatusTone = "default", + projectName, + projects, + transportState, + onBpmChange, + onModeChange, + onProjectCreate, + onProjectDelete, + onProjectRename, + onProjectSelect, + onTransportStateChange, +}: TransportBarProps) { + const [isProjectMenuOpen, setIsProjectMenuOpen] = useState(false); + const isPlaying = transportState === "playing"; + const statusText = + transportState === "playing" + ? "Playing" + : transportState === "paused" + ? "Paused" + : "Stopped"; + + return ( +
+
+
m
+
+

mini DAW

+

{statusText}

+
+
+ +
+ + + +
+ +
+ {bpm} + BPM + onBpmChange(Number(event.target.value))} + type="range" + value={bpm} + /> +
+ +
+ + +
+ +
+ + {persistenceStatusLabel} + +
+ + + {isProjectMenuOpen ? ( +
+

Local Projects

+
+ {projects.map((project) => ( + + ))} +
+ +
+ + + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/src/features/transport/index.ts b/src/features/transport/index.ts new file mode 100644 index 0000000..34cc025 --- /dev/null +++ b/src/features/transport/index.ts @@ -0,0 +1,2 @@ +export { TransportBar } from "./TransportBar"; +export type { TransportMode, TransportState } from "./TransportBar"; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..057a5e7 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./app/App"; +import "./styles/global.css"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element not found."); +} + +createRoot(rootElement).render( + + + , +); diff --git a/src/model/arrangement.ts b/src/model/arrangement.ts new file mode 100644 index 0000000..39e3c7d --- /dev/null +++ b/src/model/arrangement.ts @@ -0,0 +1,294 @@ +import type { Tick } from "../utils"; +import { + TICKS_PER_4_4_BAR, + TICKS_PER_BEAT, + secondsToTicks, +} from "../utils"; +import { isAudioClip, isHybridClip, type Clip } from "./audio-clip"; + +export type TrackId = string; +export type ClipInstanceId = string; + +export interface ArrangementTrack { + id: TrackId; + name: string; +} + +export interface ClipInstance { + id: ClipInstanceId; + clipId: string; + trackId: TrackId; + startTick: Tick; + lengthTicks: Tick; + sourceOffsetSeconds?: number; +} + +export interface ArrangementLoopRange { + startTick: Tick; + endTick: Tick; +} + +export interface ArrangementState { + lengthBars: number; + loopRange: ArrangementLoopRange; +} + +export const ARRANGEMENT_TRACK_COUNT = 12; +export const MIN_ARRANGEMENT_LENGTH_BARS = 1; +export const DEFAULT_ARRANGEMENT_LENGTH_BARS = 16; +export const MAX_ARRANGEMENT_LENGTH_BARS = 128; +export const ARRANGEMENT_BAR_COUNT = DEFAULT_ARRANGEMENT_LENGTH_BARS; +export const ARRANGEMENT_SNAP_TICKS = TICKS_PER_BEAT; +export const ARRANGEMENT_VISIBLE_LENGTH_TICKS = + getArrangementLengthTicks(DEFAULT_ARRANGEMENT_LENGTH_BARS); +export const ARRANGEMENT_CLIP_DRAG_TYPE = "application/x-mini-daw-clip-id"; +export const ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE = + "application/x-mini-daw-clip-instance-id"; + +export function createDefaultArrangementTracks( + count = ARRANGEMENT_TRACK_COUNT, +): ArrangementTrack[] { + return Array.from({ length: count }, (_, index) => ({ + id: `track-${index + 1}`, + name: `Track ${index + 1}`, + })); +} + +export function getArrangementLengthTicks(lengthBars: number): Tick { + return normalizeArrangementLengthBars(lengthBars) * TICKS_PER_4_4_BAR; +} + +export function normalizeArrangementLengthBars(lengthBars: number): number { + if (!Number.isFinite(lengthBars)) { + return DEFAULT_ARRANGEMENT_LENGTH_BARS; + } + + return clampInteger( + Math.round(lengthBars), + MIN_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + ); +} + +export function createDefaultArrangementLoopRange( + lengthBars = DEFAULT_ARRANGEMENT_LENGTH_BARS, +): ArrangementLoopRange { + return { + endTick: getArrangementLengthTicks(lengthBars), + startTick: 0, + }; +} + +export function createClipInstance({ + clip, + existingInstanceIds, + startTick, + tempoBpm, + trackId, +}: { + clip: Clip; + existingInstanceIds: readonly ClipInstanceId[]; + startTick: Tick; + tempoBpm: number; + trackId: TrackId; +}): ClipInstance { + const snappedStartTick = snapArrangementTick(startTick); + const lengthTicks = getDefaultClipInstanceLength({ clip, tempoBpm }); + + return { + clipId: clip.id, + id: createUniqueClipInstanceId({ + clipId: clip.id, + existingInstanceIds, + startTick: snappedStartTick, + trackId, + }), + lengthTicks, + startTick: snappedStartTick, + trackId, + }; +} + +export function moveClipInstance({ + instance, + startTick, + trackId, +}: { + instance: ClipInstance; + startTick: Tick; + trackId: TrackId; +}): ClipInstance { + return { + ...instance, + startTick: snapArrangementTick(startTick), + trackId, + }; +} + +export function deleteClipInstance( + instances: readonly ClipInstance[], + instanceId: ClipInstanceId, +): ClipInstance[] { + return instances.filter((instance) => instance.id !== instanceId); +} + +export function snapArrangementTick( + tick: Tick, + snapTicks = ARRANGEMENT_SNAP_TICKS, +): Tick { + if (!Number.isFinite(tick)) { + return 0; + } + + if (!Number.isFinite(snapTicks) || snapTicks <= 0) { + throw new Error(`snapTicks must be positive. Received ${snapTicks}.`); + } + + return Math.max(0, Math.round(tick / snapTicks) * snapTicks); +} + +export function getArrangementPlaybackEndTick( + instances: readonly ClipInstance[], + minimumEndTick = getArrangementLengthTicks(DEFAULT_ARRANGEMENT_LENGTH_BARS), +): Tick { + const lastInstanceEndTick = instances.reduce( + (highestEndTick, instance) => + Math.max(highestEndTick, instance.startTick + instance.lengthTicks), + 0, + ); + + return Math.max(minimumEndTick, ceilTickToSnap(lastInstanceEndTick)); +} + +export function normalizeArrangementLoopRange({ + endTick, + startTick, +}: ArrangementLoopRange, lengthBars = DEFAULT_ARRANGEMENT_LENGTH_BARS): ArrangementLoopRange { + const maxEndBoundaryIndex = normalizeArrangementLengthBars(lengthBars); + const startBoundaryIndex = clampInteger( + Math.round(startTick / TICKS_PER_4_4_BAR), + 0, + maxEndBoundaryIndex - 1, + ); + const minimumEndBoundaryIndex = startBoundaryIndex + 1; + const endBoundaryIndex = clampInteger( + Math.round(endTick / TICKS_PER_4_4_BAR), + minimumEndBoundaryIndex, + maxEndBoundaryIndex, + ); + + return { + endTick: endBoundaryIndex * TICKS_PER_4_4_BAR, + startTick: startBoundaryIndex * TICKS_PER_4_4_BAR, + }; +} + +export function getArrangementLoopBoundaryIndex(tick: Tick): number { + return getArrangementLoopBoundaryIndexForLength( + tick, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + ); +} + +export function getArrangementLoopBoundaryIndexForLength( + tick: Tick, + lengthBars: number, +): number { + return clampInteger( + Math.round(tick / TICKS_PER_4_4_BAR), + 0, + normalizeArrangementLengthBars(lengthBars), + ); +} + +export function getClipInstancesOutsideArrangementLength({ + instances, + lengthBars, +}: { + instances: readonly ClipInstance[]; + lengthBars: number; +}): ClipInstance[] { + const arrangementLengthTicks = getArrangementLengthTicks(lengthBars); + + return instances.filter( + (instance) => instance.startTick + instance.lengthTicks > arrangementLengthTicks, + ); +} + +export function removeClipInstancesOutsideArrangementLength({ + instances, + lengthBars, +}: { + instances: readonly ClipInstance[]; + lengthBars: number; +}): ClipInstance[] { + const outOfRangeInstanceIds = new Set( + getClipInstancesOutsideArrangementLength({ instances, lengthBars }).map( + (instance) => instance.id, + ), + ); + + return instances.filter((instance) => !outOfRangeInstanceIds.has(instance.id)); +} + +function getDefaultClipInstanceLength({ + clip, + tempoBpm, +}: { + clip: Clip; + tempoBpm: number; +}): Tick { + if (isHybridClip(clip)) { + return clip.lengthTicks; + } + + if (isAudioClip(clip)) { + return Math.max( + ARRANGEMENT_SNAP_TICKS, + ceilTickToSnap(secondsToTicks(clip.durationSeconds, { tempoBpm })), + ); + } + + return TICKS_PER_4_4_BAR; +} + +function ceilTickToSnap(tick: Tick, snapTicks = ARRANGEMENT_SNAP_TICKS): Tick { + if (!Number.isFinite(tick) || tick <= 0) { + return snapTicks; + } + + return Math.ceil(tick / snapTicks) * snapTicks; +} + +function createUniqueClipInstanceId({ + clipId, + existingInstanceIds, + startTick, + trackId, +}: { + clipId: string; + existingInstanceIds: readonly ClipInstanceId[]; + startTick: Tick; + trackId: TrackId; +}): ClipInstanceId { + const baseId = `clip-instance-${clipId}-${trackId}-${startTick}`; + const existingIds = new Set(existingInstanceIds); + + if (!existingIds.has(baseId)) { + return baseId; + } + + let suffix = 2; + let candidate = `${baseId}-${suffix}`; + + while (existingIds.has(candidate)) { + suffix += 1; + candidate = `${baseId}-${suffix}`; + } + + return candidate; +} + +function clampInteger(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} diff --git a/src/model/audio-clip.ts b/src/model/audio-clip.ts new file mode 100644 index 0000000..39a23d9 --- /dev/null +++ b/src/model/audio-clip.ts @@ -0,0 +1,207 @@ +import type { HybridClip } from "./drum-clip"; + +export interface SampleMeta { + id: string; + name: string; + durationSeconds?: number; + source: { + kind: "bundled" | "imported"; + fileName?: string; + mimeType?: string; + path?: string; + }; +} + +export interface AudioClip { + durationSeconds: number; + id: string; + kind: "audio"; + mimeType: string; + name: string; + sampleId: string; + sourceFileName: string; +} + +export type Clip = HybridClip | AudioClip; + +export interface ImportedAudioFileLike { + name: string; + size?: number; + type?: string; +} + +export interface ImportedAudioClipDraft { + clip: AudioClip; + sampleMeta: SampleMeta; +} + +export interface ClipDeleteConfirmationOptions { + arrangementInstanceCount?: number; + clip: Clip; +} + +const WAV_MIME_TYPES = new Set([ + "audio/wav", + "audio/wave", + "audio/x-wav", + "audio/vnd.wave", +]); + +export function isAudioClip(clip: { kind?: string }): clip is AudioClip { + return clip.kind === "audio"; +} + +export function isHybridClip( + clip: TClip, +): clip is TClip & { kind: "hybrid" } { + return clip.kind === "hybrid"; +} + +export function getClipDeleteConfirmationMessage({ + arrangementInstanceCount = 0, + clip, +}: ClipDeleteConfirmationOptions): string | null { + const hasArrangementInstances = arrangementInstanceCount > 0; + const arrangementMessage = hasArrangementInstances + ? `${arrangementInstanceCount} arrangement ${arrangementInstanceCount === 1 ? "placement" : "placements"} will also be removed.` + : ""; + + if (isAudioClip(clip)) { + return joinConfirmationParts([ + `Delete imported audio clip ${clip.name}?`, + arrangementMessage, + ]); + } + + const hasMusicalEvents = + clip.drumEvents.length > 0 || clip.noteEvents.length > 0; + + if (!hasMusicalEvents && !hasArrangementInstances) { + return null; + } + + if (hasMusicalEvents) { + return joinConfirmationParts([ + `Delete ${clip.name} and its musical events?`, + arrangementMessage, + ]); + } + + return `Delete ${clip.name}? ${arrangementMessage}`; +} + +export function validateImportedWavFile(file: ImportedAudioFileLike): void { + const hasWavExtension = /\.wav$/i.test(file.name); + const hasWavMimeType = file.type ? WAV_MIME_TYPES.has(file.type) : false; + + if (!hasWavExtension && !hasWavMimeType) { + throw new Error("Only WAV files can be imported."); + } + + if (typeof file.size === "number" && file.size <= 0) { + throw new Error("The selected WAV file is empty."); + } +} + +export function createImportedAudioDisplayName(fileName: string): string { + const baseName = getBaseFileName(fileName); + const withoutExtension = baseName.replace(/\.[^.]+$/u, ""); + const displayName = withoutExtension.replace(/[_-]+/gu, " ").trim(); + + return displayName || "Imported Audio"; +} + +export function createImportedAudioIds({ + existingClipIds, + existingSampleIds, + fileName, +}: { + existingClipIds: readonly string[]; + existingSampleIds: readonly string[]; + fileName: string; +}): { + clipId: string; + sampleId: string; +} { + const slug = slugify(createImportedAudioDisplayName(fileName)); + + return { + clipId: createUniqueId(`audio-clip-${slug}`, existingClipIds), + sampleId: createUniqueId(`imported-audio-${slug}`, existingSampleIds), + }; +} + +export function createImportedAudioClipDraft({ + clipId, + durationSeconds, + fileName, + mimeType, + sampleId, +}: { + clipId: string; + durationSeconds: number; + fileName: string; + mimeType: string; + sampleId: string; +}): ImportedAudioClipDraft { + const name = createImportedAudioDisplayName(fileName); + const normalizedMimeType = mimeType || "audio/wav"; + + return { + clip: { + durationSeconds, + id: clipId, + kind: "audio", + mimeType: normalizedMimeType, + name, + sampleId, + sourceFileName: fileName, + }, + sampleMeta: { + durationSeconds, + id: sampleId, + name, + source: { + fileName, + kind: "imported", + mimeType: normalizedMimeType, + }, + }, + }; +} + +function getBaseFileName(fileName: string): string { + return fileName.split(/[\\/]/u).pop() ?? fileName; +} + +function slugify(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + + return slug || "audio"; +} + +function createUniqueId(baseId: string, existingIds: readonly string[]): string { + const existingIdSet = new Set(existingIds); + + if (!existingIdSet.has(baseId)) { + return baseId; + } + + let suffix = 2; + let candidate = `${baseId}-${suffix}`; + + while (existingIdSet.has(candidate)) { + suffix += 1; + candidate = `${baseId}-${suffix}`; + } + + return candidate; +} + +function joinConfirmationParts(parts: readonly string[]): string { + return parts.filter(Boolean).join(" "); +} diff --git a/src/model/drum-clip.ts b/src/model/drum-clip.ts new file mode 100644 index 0000000..b805ff3 --- /dev/null +++ b/src/model/drum-clip.ts @@ -0,0 +1,982 @@ +import { + TICKS_PER_4_4_BAR, + TICKS_PER_16_STEP, + type Tick, +} from "../utils"; + +export type DrumLaneId = "kick" | "snare" | "closedHat" | "openHat"; +export type PitchedInstrumentId = "default-synth" | "iowa-piano"; +export type DrumStepSubdivision = 1 | 2 | 3; +export type HybridClipLengthBars = 1 | 2 | 4; + +export interface DrumLaneDefinition { + id: DrumLaneId; + label: string; + sampleId: string; +} + +export interface DrumEvent { + id: string; + laneId: DrumLaneId; + sampleId: string; + startTick: Tick; + velocity: number; +} + +export interface NoteEvent { + id: string; + instrumentId: PitchedInstrumentId; + midiNote: number; + startTick: Tick; + durationTicks: Tick; + velocity: number; +} + +export interface PianoRollPitch { + keyType: "white" | "black"; + label: string; + midiNote: number; + sampleId: string; +} + +export interface HybridClip { + kind: "hybrid"; + id: string; + name: string; + lengthTicks: Tick; + drumStepSubdivision: DrumStepSubdivision; + drumLanes: DrumLaneDefinition[]; + drumEvents: DrumEvent[]; + pitchedInstrumentIds: PitchedInstrumentId[]; + noteEvents: NoteEvent[]; +} + +export const DRUM_LANES = [ + { id: "kick", label: "FRED KICK 1", sampleId: "fred-kick-1" }, + { id: "snare", label: "FRED SNARE 1", sampleId: "fred-snare-1" }, + { + id: "closedHat", + label: "FRED CLOSED HI-HAT", + sampleId: "fred-closed-hi-hat", + }, + { + id: "openHat", + label: "FRED OPEN HI-HAT", + sampleId: "fred-open-hi-hat", + }, +] as const satisfies readonly DrumLaneDefinition[]; + +export const DEFAULT_DRUM_VELOCITY = 1; +export const DEFAULT_NOTE_VELOCITY = 0.8; +export const DEFAULT_HYBRID_CLIP_LENGTH_BARS: HybridClipLengthBars = 1; +export const HYBRID_CLIP_LENGTH_BARS = [ + 1, + 2, + 4, +] as const satisfies readonly HybridClipLengthBars[]; +export const DEFAULT_PITCHED_INSTRUMENT_ID: PitchedInstrumentId = "default-synth"; +export const INITIAL_PITCHED_INSTRUMENT_IDS = + [] as const satisfies readonly PitchedInstrumentId[]; +export const DEFAULT_DRUM_STEP_SUBDIVISION: DrumStepSubdivision = 1; +export const DRUM_STEP_SUBDIVISIONS = [ + 1, + 2, + 3, +] as const satisfies readonly DrumStepSubdivision[]; +export const DRUM_STEPS_PER_BAR = 16; +export const DRUM_STEP_COUNT = DRUM_STEPS_PER_BAR; +export const PIANO_ROLL_COLUMNS_PER_BAR = 32; +export const PIANO_ROLL_COLUMN_COUNT = PIANO_ROLL_COLUMNS_PER_BAR; +export const TICKS_PER_PIANO_ROLL_COLUMN = + TICKS_PER_4_4_BAR / PIANO_ROLL_COLUMN_COUNT; + +export const PIANO_ROLL_PITCHES = [ + { + keyType: "white", + label: "C5", + midiNote: 72, + sampleId: "iowa-piano-c5", + }, + { + keyType: "white", + label: "B4", + midiNote: 71, + sampleId: "iowa-piano-b4", + }, + { + keyType: "black", + label: "Bb4", + midiNote: 70, + sampleId: "iowa-piano-bb4", + }, + { + keyType: "white", + label: "A4", + midiNote: 69, + sampleId: "iowa-piano-a4", + }, + { + keyType: "black", + label: "Ab4", + midiNote: 68, + sampleId: "iowa-piano-ab4", + }, + { + keyType: "white", + label: "G4", + midiNote: 67, + sampleId: "iowa-piano-g4", + }, + { + keyType: "black", + label: "Gb4", + midiNote: 66, + sampleId: "iowa-piano-gb4", + }, + { + keyType: "white", + label: "F4", + midiNote: 65, + sampleId: "iowa-piano-f4", + }, + { + keyType: "white", + label: "E4", + midiNote: 64, + sampleId: "iowa-piano-e4", + }, + { + keyType: "black", + label: "Eb4", + midiNote: 63, + sampleId: "iowa-piano-eb4", + }, + { + keyType: "white", + label: "D4", + midiNote: 62, + sampleId: "iowa-piano-d4", + }, + { + keyType: "black", + label: "Db4", + midiNote: 61, + sampleId: "iowa-piano-db4", + }, + { + keyType: "white", + label: "C4", + midiNote: 60, + sampleId: "iowa-piano-c4", + }, +] as const satisfies readonly PianoRollPitch[]; + +export function createEmptyHybridClip({ + id = "clip-1", + lengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), + name = "Clip 1", + drumStepSubdivision = DEFAULT_DRUM_STEP_SUBDIVISION, + pitchedInstrumentIds = INITIAL_PITCHED_INSTRUMENT_IDS, +}: { + drumStepSubdivision?: DrumStepSubdivision; + id?: string; + lengthTicks?: Tick; + name?: string; + pitchedInstrumentIds?: readonly PitchedInstrumentId[]; +} = {}): HybridClip { + validateHybridClipLengthTicks(lengthTicks); + + return { + drumEvents: [], + drumLanes: cloneDrumLanes(DRUM_LANES), + drumStepSubdivision, + id, + kind: "hybrid", + lengthTicks, + name, + noteEvents: [], + pitchedInstrumentIds: [...pitchedInstrumentIds], + }; +} + +export function getHybridClipLengthTicks( + barCount: HybridClipLengthBars, +): Tick { + validateHybridClipLengthBars(barCount); + + return barCount * TICKS_PER_4_4_BAR; +} + +export function getHybridClipBarCount( + lengthTicks: Tick, +): HybridClipLengthBars { + validateHybridClipLengthTicks(lengthTicks); + + return (lengthTicks / TICKS_PER_4_4_BAR) as HybridClipLengthBars; +} + +export function getHybridClipLengthLabel(lengthTicks: Tick): string { + const barCount = getHybridClipBarCount(lengthTicks); + + return `${barCount} bar${barCount === 1 ? "" : "s"}`; +} + +export function getDrumStepCount( + lengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): number { + return getHybridClipBarCount(lengthTicks) * DRUM_STEPS_PER_BAR; +} + +export function getPianoRollColumnCount( + lengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): number { + return getHybridClipBarCount(lengthTicks) * PIANO_ROLL_COLUMNS_PER_BAR; +} + +export function hasHybridClipEventsOutsideLength({ + clip, + lengthTicks, +}: { + clip: HybridClip; + lengthTicks: Tick; +}): boolean { + validateHybridClipLengthTicks(lengthTicks); + + return ( + clip.drumEvents.some((event) => event.startTick >= lengthTicks) || + clip.noteEvents.some( + (event) => + event.startTick >= lengthTicks || + event.startTick + event.durationTicks > lengthTicks, + ) + ); +} + +export function updateHybridClipLength({ + clip, + lengthTicks, + trimEvents = false, +}: { + clip: HybridClip; + lengthTicks: Tick; + trimEvents?: boolean; +}): HybridClip { + validateHybridClipLengthTicks(lengthTicks); + + if (clip.lengthTicks === lengthTicks) { + return clip; + } + + if ( + !trimEvents && + hasHybridClipEventsOutsideLength({ + clip, + lengthTicks, + }) + ) { + throw new Error( + "Cannot shorten clip while drum or note events extend outside the new length.", + ); + } + + return { + ...clip, + drumEvents: trimEvents + ? clip.drumEvents.filter((event) => event.startTick < lengthTicks) + : clip.drumEvents, + lengthTicks, + noteEvents: trimEvents + ? clip.noteEvents + .filter((event) => event.startTick < lengthTicks) + .map((event) => ({ + ...event, + durationTicks: Math.min( + event.durationTicks, + lengthTicks - event.startTick, + ), + })) + : clip.noteEvents, + }; +} + +export function renameClip({ + clip, + name, +}: { + clip: TClip; + name: string; +}): TClip { + const trimmedName = name.trim(); + + if (!trimmedName || trimmedName === clip.name) { + return clip; + } + + return { + ...clip, + name: trimmedName, + }; +} + +export function addPitchedInstrumentToClip({ + clip, + instrumentId, +}: { + clip: HybridClip; + instrumentId: PitchedInstrumentId; +}): HybridClip { + if (clip.pitchedInstrumentIds.includes(instrumentId)) { + return clip; + } + + return { + ...clip, + pitchedInstrumentIds: [...clip.pitchedInstrumentIds, instrumentId], + }; +} + +export function removePitchedInstrumentFromClip({ + clip, + instrumentId, + removeOwnedNotes = false, +}: { + clip: HybridClip; + instrumentId: PitchedInstrumentId; + removeOwnedNotes?: boolean; +}): HybridClip { + if (!clip.pitchedInstrumentIds.includes(instrumentId)) { + return clip; + } + + return { + ...clip, + noteEvents: removeOwnedNotes + ? clip.noteEvents.filter((event) => event.instrumentId !== instrumentId) + : clip.noteEvents, + pitchedInstrumentIds: clip.pitchedInstrumentIds.filter( + (candidate) => candidate !== instrumentId, + ), + }; +} + +export function hasNoteEventsForPitchedInstrument({ + clip, + instrumentId, +}: { + clip: HybridClip; + instrumentId: PitchedInstrumentId; +}): boolean { + return clip.noteEvents.some((event) => event.instrumentId === instrumentId); +} + +export function getDrumStepStartTick( + stepIndex: number, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): Tick { + validateStepIndex(stepIndex, clipLengthTicks); + + return stepIndex * TICKS_PER_16_STEP; +} + +export function getDrumSubstepTicks( + subdivision: DrumStepSubdivision, +): Tick { + validateDrumStepSubdivision(subdivision); + + return TICKS_PER_16_STEP / subdivision; +} + +export function getDrumSubstepStartTick({ + stepIndex, + substepIndex, + subdivision, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +}: { + clipLengthTicks?: Tick; + stepIndex: number; + substepIndex: number; + subdivision: DrumStepSubdivision; +}): Tick { + validateStepIndex(stepIndex, clipLengthTicks); + validateSubstepIndex(substepIndex, subdivision); + + return ( + getDrumStepStartTick(stepIndex, clipLengthTicks) + + substepIndex * getDrumSubstepTicks(subdivision) + ); +} + +export function isDrumStepActive( + drumEvents: readonly DrumEvent[], + laneId: DrumLaneId, + stepIndex: number, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): boolean { + const startTick = getDrumStepStartTick(stepIndex, clipLengthTicks); + + return drumEvents.some( + (event) => event.laneId === laneId && event.startTick === startTick, + ); +} + +export function isDrumSubstepActive({ + drumEvents, + laneId, + stepIndex, + substepIndex, + subdivision, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +}: { + clipLengthTicks?: Tick; + drumEvents: readonly DrumEvent[]; + laneId: DrumLaneId; + stepIndex: number; + substepIndex: number; + subdivision: DrumStepSubdivision; +}): boolean { + const startTick = getDrumSubstepStartTick({ + clipLengthTicks, + stepIndex, + subdivision, + substepIndex, + }); + + return drumEvents.some( + (event) => event.laneId === laneId && event.startTick === startTick, + ); +} + +export function toggleDrumStep({ + clip, + laneId, + stepIndex, + velocity = DEFAULT_DRUM_VELOCITY, +}: { + clip: HybridClip; + laneId: DrumLaneId; + stepIndex: number; + velocity?: number; +}): HybridClip { + return toggleDrumSubstep({ + clip, + laneId, + stepIndex, + substepIndex: 0, + velocity, + }); +} + +export function toggleDrumSubstep({ + clip, + laneId, + stepIndex, + substepIndex, + velocity = DEFAULT_DRUM_VELOCITY, +}: { + clip: HybridClip; + laneId: DrumLaneId; + stepIndex: number; + substepIndex: number; + velocity?: number; +}): HybridClip { + const lane = getDrumLane(clip.drumLanes, laneId); + const startTick = getDrumSubstepStartTick({ + clipLengthTicks: clip.lengthTicks, + stepIndex, + subdivision: clip.drumStepSubdivision, + substepIndex, + }); + const eventExists = isDrumSubstepActive({ + drumEvents: clip.drumEvents, + clipLengthTicks: clip.lengthTicks, + laneId, + stepIndex, + subdivision: clip.drumStepSubdivision, + substepIndex, + }); + + if (eventExists) { + return { + ...clip, + drumEvents: clip.drumEvents.filter( + (event) => !(event.laneId === laneId && event.startTick === startTick), + ), + }; + } + + const drumEvents = [ + ...clip.drumEvents, + { + id: createDrumEventId(clip.id, laneId, startTick), + laneId, + sampleId: lane.sampleId, + startTick, + velocity, + }, + ]; + + drumEvents.sort(createDrumEventComparator(clip.drumLanes)); + + return { + ...clip, + drumEvents, + }; +} + +export function updateDrumStepSubdivision({ + clip, + subdivision, +}: { + clip: HybridClip; + subdivision: DrumStepSubdivision; +}): HybridClip { + validateDrumStepSubdivision(subdivision); + + if (clip.drumStepSubdivision === subdivision) { + return clip; + } + + return { + ...clip, + drumStepSubdivision: subdivision, + }; +} + +export function updateDrumLaneSample({ + clip, + label, + laneId, + sampleId, +}: { + clip: HybridClip; + label: string; + laneId: DrumLaneId; + sampleId: string; +}): HybridClip { + getDrumLane(clip.drumLanes, laneId); + + return { + ...clip, + drumEvents: clip.drumEvents.map((event) => + event.laneId === laneId + ? { + ...event, + sampleId, + } + : event, + ), + drumLanes: clip.drumLanes.map((lane) => + lane.id === laneId + ? { + ...lane, + label, + sampleId, + } + : lane, + ), + }; +} + +export function moveDrumLane({ + clip, + laneId, + targetIndex, +}: { + clip: HybridClip; + laneId: DrumLaneId; + targetIndex: number; +}): HybridClip { + const currentIndex = clip.drumLanes.findIndex((lane) => lane.id === laneId); + + if (currentIndex < 0) { + throw new Error(`Unknown drum lane ID: ${laneId}`); + } + + const nextDrumLanes = [...clip.drumLanes]; + const [movedLane] = nextDrumLanes.splice(currentIndex, 1); + + if (!movedLane) { + throw new Error(`Unknown drum lane ID: ${laneId}`); + } + + const boundedTargetIndex = Math.min( + Math.max(targetIndex, 0), + nextDrumLanes.length, + ); + + nextDrumLanes.splice(boundedTargetIndex, 0, movedLane); + + return { + ...clip, + drumEvents: [...clip.drumEvents].sort( + createDrumEventComparator(nextDrumLanes), + ), + drumLanes: nextDrumLanes, + }; +} + +export function getPianoRollColumnStartTick( + columnIndex: number, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): Tick { + validatePianoRollColumnIndex(columnIndex, clipLengthTicks); + + return columnIndex * TICKS_PER_PIANO_ROLL_COLUMN; +} + +export function getPianoRollPitchByMidiNote( + midiNote: number, +): PianoRollPitch | undefined { + return PIANO_ROLL_PITCHES.find((pitch) => pitch.midiNote === midiNote); +} + +export function addNoteEvent({ + clip, + durationTicks, + instrumentId = DEFAULT_PITCHED_INSTRUMENT_ID, + midiNote, + startTick, + velocity = DEFAULT_NOTE_VELOCITY, +}: { + clip: HybridClip; + durationTicks: Tick; + instrumentId?: PitchedInstrumentId; + midiNote: number; + startTick: Tick; + velocity?: number; +}): HybridClip { + validateMidiNote(midiNote); + validateVelocity(velocity); + + const nextTiming = normalizeNoteTiming({ + durationTicks, + lengthTicks: clip.lengthTicks, + startTick, + }); + const nextNote: NoteEvent = { + durationTicks: nextTiming.durationTicks, + id: createNoteEventId(clip.id, instrumentId, midiNote, nextTiming.startTick), + instrumentId, + midiNote, + startTick: nextTiming.startTick, + velocity, + }; + const noteEvents = [ + ...clip.noteEvents.filter( + (event) => + !( + event.instrumentId === nextNote.instrumentId && + event.midiNote === nextNote.midiNote && + event.startTick === nextNote.startTick + ), + ), + nextNote, + ]; + + noteEvents.sort(createNoteEventComparator); + + return { + ...clip, + noteEvents, + }; +} + +export function deleteNoteEvent({ + clip, + noteId, +}: { + clip: HybridClip; + noteId: string; +}): HybridClip { + return { + ...clip, + noteEvents: clip.noteEvents.filter((event) => event.id !== noteId), + }; +} + +export function moveNoteEvent({ + clip, + midiNote, + noteId, + startTick, +}: { + clip: HybridClip; + midiNote: number; + noteId: string; + startTick: Tick; +}): HybridClip { + validateMidiNote(midiNote); + + const note = clip.noteEvents.find((event) => event.id === noteId); + + if (!note) { + return clip; + } + + const nextTiming = normalizeNoteTiming({ + durationTicks: note.durationTicks, + lengthTicks: clip.lengthTicks, + startTick, + }); + const movedNote: NoteEvent = { + ...note, + midiNote, + startTick: nextTiming.startTick, + }; + const noteEvents = [ + ...clip.noteEvents.filter( + (event) => + event.id !== noteId && + !( + event.instrumentId === movedNote.instrumentId && + event.midiNote === movedNote.midiNote && + event.startTick === movedNote.startTick + ), + ), + movedNote, + ]; + + noteEvents.sort(createNoteEventComparator); + + return { + ...clip, + noteEvents, + }; +} + +export function resizeNoteEvent({ + clip, + durationTicks, + noteId, +}: { + clip: HybridClip; + durationTicks: Tick; + noteId: string; +}): HybridClip { + const note = clip.noteEvents.find((event) => event.id === noteId); + + if (!note) { + return clip; + } + + const nextTiming = normalizeNoteTiming({ + durationTicks, + lengthTicks: clip.lengthTicks, + startTick: note.startTick, + }); + + return { + ...clip, + noteEvents: clip.noteEvents.map((event) => + event.id === noteId + ? { + ...event, + durationTicks: nextTiming.durationTicks, + } + : event, + ), + }; +} + +function getDrumLane( + drumLanes: readonly DrumLaneDefinition[], + laneId: DrumLaneId, +): DrumLaneDefinition { + const lane = drumLanes.find((candidate) => candidate.id === laneId); + + if (!lane) { + throw new Error(`Unknown drum lane ID: ${laneId}`); + } + + return lane; +} + +function createDrumEventComparator( + drumLanes: readonly DrumLaneDefinition[], +): (left: DrumEvent, right: DrumEvent) => number { + const drumLaneOrder = new Map( + drumLanes.map((lane, index) => [lane.id, index]), + ); + + return (left, right) => { + if (left.startTick !== right.startTick) { + return left.startTick - right.startTick; + } + + return ( + getLaneOrder(drumLaneOrder, left.laneId) - + getLaneOrder(drumLaneOrder, right.laneId) + ); + }; +} + +function getLaneOrder( + drumLaneOrder: ReadonlyMap, + laneId: DrumLaneId, +): number { + return drumLaneOrder.get(laneId) ?? Number.MAX_SAFE_INTEGER; +} + +function createDrumEventId( + clipId: string, + laneId: DrumLaneId, + startTick: Tick, +): string { + return `${clipId}:drum:${laneId}:${startTick}`; +} + +function createNoteEventId( + clipId: string, + instrumentId: PitchedInstrumentId, + midiNote: number, + startTick: Tick, +): string { + return `${clipId}:note:${instrumentId}:${midiNote}:${startTick}`; +} + +function createNoteEventComparator(left: NoteEvent, right: NoteEvent): number { + if (left.startTick !== right.startTick) { + return left.startTick - right.startTick; + } + + if (left.instrumentId !== right.instrumentId) { + return left.instrumentId.localeCompare(right.instrumentId); + } + + return right.midiNote - left.midiNote; +} + +function validateHybridClipLengthBars( + barCount: number, +): asserts barCount is HybridClipLengthBars { + if (!HYBRID_CLIP_LENGTH_BARS.includes(barCount as HybridClipLengthBars)) { + throw new Error( + `barCount must be one of ${HYBRID_CLIP_LENGTH_BARS.join(", ")}. Received ${barCount}.`, + ); + } +} + +function validateHybridClipLengthTicks(lengthTicks: Tick): void { + if (!Number.isFinite(lengthTicks)) { + throw new Error(`lengthTicks must be finite. Received ${lengthTicks}.`); + } + + const barCount = lengthTicks / TICKS_PER_4_4_BAR; + + validateHybridClipLengthBars(barCount); +} + +function validateStepIndex( + stepIndex: number, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): void { + const stepCount = getDrumStepCount(clipLengthTicks); + + if (!Number.isInteger(stepIndex) || stepIndex < 0 || stepIndex >= stepCount) { + throw new Error( + `stepIndex must be an integer from 0 to ${ + stepCount - 1 + }. Received ${stepIndex}.`, + ); + } +} + +function validateDrumStepSubdivision( + subdivision: number, +): asserts subdivision is DrumStepSubdivision { + if (!DRUM_STEP_SUBDIVISIONS.includes(subdivision as DrumStepSubdivision)) { + throw new Error( + `subdivision must be one of ${DRUM_STEP_SUBDIVISIONS.join(", ")}. Received ${subdivision}.`, + ); + } +} + +function validateSubstepIndex( + substepIndex: number, + subdivision: DrumStepSubdivision, +): void { + validateDrumStepSubdivision(subdivision); + + if ( + !Number.isInteger(substepIndex) || + substepIndex < 0 || + substepIndex >= subdivision + ) { + throw new Error( + `substepIndex must be an integer from 0 to ${subdivision - 1}. Received ${substepIndex}.`, + ); + } +} + +function validatePianoRollColumnIndex( + columnIndex: number, + clipLengthTicks = getHybridClipLengthTicks(DEFAULT_HYBRID_CLIP_LENGTH_BARS), +): void { + const columnCount = getPianoRollColumnCount(clipLengthTicks); + + if ( + !Number.isInteger(columnIndex) || + columnIndex < 0 || + columnIndex >= columnCount + ) { + throw new Error( + `columnIndex must be an integer from 0 to ${ + columnCount - 1 + }. Received ${columnIndex}.`, + ); + } +} + +function validateMidiNote(midiNote: number): void { + if (!Number.isInteger(midiNote) || midiNote < 0 || midiNote > 127) { + throw new Error(`midiNote must be an integer from 0 to 127. Received ${midiNote}.`); + } +} + +function validateVelocity(velocity: number): void { + if (!Number.isFinite(velocity) || velocity < 0 || velocity > 1) { + throw new Error(`velocity must be a number from 0 to 1. Received ${velocity}.`); + } +} + +function normalizeNoteTiming({ + durationTicks, + lengthTicks, + startTick, +}: { + durationTicks: Tick; + lengthTicks: Tick; + startTick: Tick; +}): { durationTicks: Tick; startTick: Tick } { + if (!Number.isFinite(startTick)) { + throw new Error(`startTick must be finite. Received ${startTick}.`); + } + + validateHybridClipLengthTicks(lengthTicks); + + if (!Number.isFinite(durationTicks) || durationTicks <= 0) { + throw new Error( + `durationTicks must be a positive finite number. Received ${durationTicks}.`, + ); + } + + const boundedStartTick = Math.min( + Math.max(startTick, 0), + lengthTicks - TICKS_PER_PIANO_ROLL_COLUMN, + ); + const boundedDurationTicks = Math.min( + durationTicks, + lengthTicks - boundedStartTick, + ); + + return { + durationTicks: boundedDurationTicks, + startTick: boundedStartTick, + }; +} + +function cloneDrumLanes( + drumLanes: readonly DrumLaneDefinition[], +): DrumLaneDefinition[] { + return drumLanes.map((lane) => ({ ...lane })); +} diff --git a/src/model/index.ts b/src/model/index.ts new file mode 100644 index 0000000..a636e16 --- /dev/null +++ b/src/model/index.ts @@ -0,0 +1,135 @@ +export { + ARRANGEMENT_BAR_COUNT, + ARRANGEMENT_CLIP_DRAG_TYPE, + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, + ARRANGEMENT_SNAP_TICKS, + ARRANGEMENT_TRACK_COUNT, + ARRANGEMENT_VISIBLE_LENGTH_TICKS, + createClipInstance, + createDefaultArrangementLoopRange, + createDefaultArrangementTracks, + deleteClipInstance, + getArrangementLengthTicks, + getArrangementLoopBoundaryIndex, + getArrangementLoopBoundaryIndexForLength, + getArrangementPlaybackEndTick, + getClipInstancesOutsideArrangementLength, + moveClipInstance, + normalizeArrangementLoopRange, + normalizeArrangementLengthBars, + removeClipInstancesOutsideArrangementLength, + snapArrangementTick, +} from "./arrangement"; +export { + DEFAULT_DRUM_VELOCITY, + DEFAULT_DRUM_STEP_SUBDIVISION, + DEFAULT_HYBRID_CLIP_LENGTH_BARS, + DEFAULT_NOTE_VELOCITY, + DRUM_LANES, + DRUM_STEP_COUNT, + DRUM_STEPS_PER_BAR, + DRUM_STEP_SUBDIVISIONS, + HYBRID_CLIP_LENGTH_BARS, + INITIAL_PITCHED_INSTRUMENT_IDS, + PIANO_ROLL_COLUMN_COUNT, + PIANO_ROLL_COLUMNS_PER_BAR, + PIANO_ROLL_PITCHES, + TICKS_PER_PIANO_ROLL_COLUMN, + addPitchedInstrumentToClip, + addNoteEvent, + createEmptyHybridClip, + deleteNoteEvent, + getDrumSubstepStartTick, + getDrumSubstepTicks, + getDrumStepCount, + getDrumStepStartTick, + getHybridClipBarCount, + getHybridClipLengthLabel, + getHybridClipLengthTicks, + getPianoRollColumnCount, + getPianoRollColumnStartTick, + getPianoRollPitchByMidiNote, + hasHybridClipEventsOutsideLength, + hasNoteEventsForPitchedInstrument, + isDrumSubstepActive, + isDrumStepActive, + moveDrumLane, + moveNoteEvent, + removePitchedInstrumentFromClip, + renameClip, + resizeNoteEvent, + toggleDrumSubstep, + toggleDrumStep, + updateDrumLaneSample, + updateDrumStepSubdivision, + updateHybridClipLength, +} from "./drum-clip"; +export { + createImportedAudioClipDraft, + createImportedAudioDisplayName, + createImportedAudioIds, + getClipDeleteConfirmationMessage, + isAudioClip, + isHybridClip, + validateImportedWavFile, +} from "./audio-clip"; +export { + MIXER_DEFAULT_VOLUME_DB, + MIXER_MAX_VOLUME_DB, + MIXER_MIN_VOLUME_DB, + clampMixerVolumeDb, + createDefaultMasterMixerState, + createDefaultTrackMixerState, + createDefaultTrackMixerStates, + decibelsToLinearGain, + getTrackEffectiveGain, + getTrackMixerState, + isTrackMixerAudible, + updateMasterMixerState, + updateTrackMixerState, +} from "./mixer"; +export { + DEFAULT_PITCHED_INSTRUMENT_ID, + DEFAULT_SYNTH_INSTRUMENT, + IOWA_PIANO_INSTRUMENT, + PITCHED_INSTRUMENTS, + getPitchedInstrument, + getSampleZoneForMidiNote, +} from "./pitched-instruments"; +export type { + ArrangementTrack, + ArrangementLoopRange, + ArrangementState, + ClipInstance, + ClipInstanceId, + TrackId, +} from "./arrangement"; +export type { + DrumEvent, + DrumLaneDefinition, + DrumLaneId, + DrumStepSubdivision, + HybridClip, + HybridClipLengthBars, + NoteEvent, + PianoRollPitch, +} from "./drum-clip"; +export type { + AudioClip, + Clip, + ClipDeleteConfirmationOptions, + ImportedAudioClipDraft, + ImportedAudioFileLike, + SampleMeta, +} from "./audio-clip"; +export type { MasterMixerState, TrackMixerState } from "./mixer"; +export type { + PitchedInstrumentId, + PitchedInstrumentMeta, + SamplerEnvelopeMeta, + SamplerSustainMeta, + SampleZone, +} from "./pitched-instruments"; diff --git a/src/model/mixer.ts b/src/model/mixer.ts new file mode 100644 index 0000000..538a45c --- /dev/null +++ b/src/model/mixer.ts @@ -0,0 +1,133 @@ +import type { ArrangementTrack, TrackId } from "./arrangement"; + +export interface TrackMixerState { + trackId: TrackId; + volumeDb: number; + muted: boolean; + solo: boolean; +} + +export interface MasterMixerState { + volumeDb: number; +} + +export const MIXER_MIN_VOLUME_DB = -60; +export const MIXER_MAX_VOLUME_DB = 6; +export const MIXER_DEFAULT_VOLUME_DB = 0; + +export function createDefaultTrackMixerState(trackId: TrackId): TrackMixerState { + return { + muted: false, + solo: false, + trackId, + volumeDb: MIXER_DEFAULT_VOLUME_DB, + }; +} + +export function createDefaultTrackMixerStates( + tracks: readonly ArrangementTrack[], +): TrackMixerState[] { + return tracks.map((track) => createDefaultTrackMixerState(track.id)); +} + +export function createDefaultMasterMixerState(): MasterMixerState { + return { + volumeDb: MIXER_DEFAULT_VOLUME_DB, + }; +} + +export function getTrackMixerState( + states: readonly TrackMixerState[], + trackId: TrackId, +): TrackMixerState { + return ( + states.find((state) => state.trackId === trackId) ?? + createDefaultTrackMixerState(trackId) + ); +} + +export function updateTrackMixerState( + states: readonly TrackMixerState[], + trackId: TrackId, + patch: Partial>, +): TrackMixerState[] { + const hasExistingState = states.some((state) => state.trackId === trackId); + const nextStates = hasExistingState + ? states + : [...states, createDefaultTrackMixerState(trackId)]; + + return nextStates.map((state) => + state.trackId === trackId + ? { + ...state, + ...patch, + trackId, + volumeDb: + patch.volumeDb === undefined + ? state.volumeDb + : clampMixerVolumeDb(patch.volumeDb), + } + : state, + ); +} + +export function updateMasterMixerState( + state: MasterMixerState, + patch: Partial, +): MasterMixerState { + return { + ...state, + ...patch, + volumeDb: + patch.volumeDb === undefined + ? state.volumeDb + : clampMixerVolumeDb(patch.volumeDb), + }; +} + +export function isTrackMixerAudible({ + allTrackStates, + trackState, +}: { + allTrackStates: readonly TrackMixerState[]; + trackState: TrackMixerState; +}): boolean { + const hasSoloedTrack = allTrackStates.some((state) => state.solo); + + return !trackState.muted && (!hasSoloedTrack || trackState.solo); +} + +export function getTrackEffectiveGain({ + allTrackStates, + trackState, +}: { + allTrackStates: readonly TrackMixerState[]; + trackState: TrackMixerState; +}): number { + if (!isTrackMixerAudible({ allTrackStates, trackState })) { + return 0; + } + + return decibelsToLinearGain(trackState.volumeDb); +} + +export function decibelsToLinearGain(volumeDb: number): number { + const clampedVolumeDb = clampMixerVolumeDb(volumeDb); + + if (clampedVolumeDb <= MIXER_MIN_VOLUME_DB) { + return 0; + } + + return 10 ** (clampedVolumeDb / 20); +} + +export function clampMixerVolumeDb(volumeDb: number): number { + if (!Number.isFinite(volumeDb)) { + return MIXER_DEFAULT_VOLUME_DB; + } + + return Math.min( + MIXER_MAX_VOLUME_DB, + Math.max(MIXER_MIN_VOLUME_DB, volumeDb), + ); +} diff --git a/src/model/pitched-instruments.ts b/src/model/pitched-instruments.ts new file mode 100644 index 0000000..048980c --- /dev/null +++ b/src/model/pitched-instruments.ts @@ -0,0 +1,116 @@ +import { + PIANO_ROLL_PITCHES, + type PitchedInstrumentId, +} from "./drum-clip"; + +export { DEFAULT_PITCHED_INSTRUMENT_ID } from "./drum-clip"; +export type { PitchedInstrumentId } from "./drum-clip"; + +export interface PitchedInstrumentMeta { + id: PitchedInstrumentId; + kind: "sample" | "synth"; + name: string; + zones?: readonly SampleZone[]; +} + +export interface SampleZone { + envelope?: SamplerEnvelopeMeta; + midiNote: number; + rootMidiNote: number; + sampleEndSeconds?: number; + sampleStartSeconds?: number; + sampleId: string; + sustain?: SamplerSustainMeta; +} + +export interface SamplerSustainMeta { + crossfadeSeconds?: number; + loopEndSeconds?: number; + loopStartSeconds?: number; + mode: "crossfade-loop" | "forward-loop" | "none"; +} + +export interface SamplerEnvelopeMeta { + attackSeconds?: number; + releaseSeconds?: number; +} + +const IOWA_PIANO_SAMPLE_START_SECONDS: Readonly> = { + 60: 0.525, + 61: 0.562, + 62: 0.699, + 63: 0.574, + 64: 0.678, + 65: 0.597, + 66: 0.393, + 67: 0.67, + 68: 0.526, + 69: 0.26, + 70: 0.44, + 71: 0.474, + 72: 0.219, +}; +const IOWA_PIANO_LOOP_START_SECONDS = 3.4; +const IOWA_PIANO_LOOP_END_SECONDS = 4.8; +const IOWA_PIANO_ENVELOPE = { + attackSeconds: 0.012, + releaseSeconds: 0.09, +} as const satisfies SamplerEnvelopeMeta; + +export const DEFAULT_SYNTH_INSTRUMENT = { + id: "default-synth", + kind: "synth", + name: "Default Synth", +} as const satisfies PitchedInstrumentMeta; + +export const IOWA_PIANO_INSTRUMENT = { + id: "iowa-piano", + kind: "sample", + name: "Iowa Piano", + zones: PIANO_ROLL_PITCHES.map((pitch) => { + const sampleStartSeconds = + IOWA_PIANO_SAMPLE_START_SECONDS[pitch.midiNote] ?? 0; + + return { + envelope: IOWA_PIANO_ENVELOPE, + midiNote: pitch.midiNote, + rootMidiNote: pitch.midiNote, + sampleStartSeconds, + sampleId: pitch.sampleId, + sustain: { + loopEndSeconds: IOWA_PIANO_LOOP_END_SECONDS, + loopStartSeconds: IOWA_PIANO_LOOP_START_SECONDS, + mode: "forward-loop", + }, + }; + }), +} as const satisfies PitchedInstrumentMeta; + +export const PITCHED_INSTRUMENTS = [ + DEFAULT_SYNTH_INSTRUMENT, + IOWA_PIANO_INSTRUMENT, +] as const satisfies readonly PitchedInstrumentMeta[]; + +export function getPitchedInstrument( + instrumentId: PitchedInstrumentId, +): PitchedInstrumentMeta { + const instrument = PITCHED_INSTRUMENTS.find( + (candidate) => candidate.id === instrumentId, + ); + + if (!instrument) { + throw new Error(`Unknown pitched instrument ID: ${instrumentId}`); + } + + return instrument; +} + +export function getSampleZoneForMidiNote({ + instrument, + midiNote, +}: { + instrument: PitchedInstrumentMeta; + midiNote: number; +}): SampleZone | undefined { + return instrument.zones?.find((zone) => zone.midiNote === midiNote); +} diff --git a/src/persistence/index.ts b/src/persistence/index.ts new file mode 100644 index 0000000..5e7fbdd --- /dev/null +++ b/src/persistence/index.ts @@ -0,0 +1,24 @@ +export { + ACTIVE_PROJECT_ID, + DEFAULT_PROJECT_ID, + PROJECT_DOCUMENT_VERSION, + createProjectCollectionState, + createProjectId, + createProjectSampleBlobId, + createProjectSummary, + createIndexedDbProjectStore, + createPersistedProjectDocument, + getImportedSampleIds, + migrateProjectCollectionState, + migratePersistedProjectDocument, + removeProjectSummary, + upsertProjectSummary, +} from "./project-store"; +export type { + ImportedSampleBlobRecord, + PersistedProjectDocument, + ProjectCollectionState, + ProjectSummary, + ProjectStore, + SaveImportedSampleBlobOptions, +} from "./project-store"; diff --git a/src/persistence/project-store.ts b/src/persistence/project-store.ts new file mode 100644 index 0000000..68cfed3 --- /dev/null +++ b/src/persistence/project-store.ts @@ -0,0 +1,761 @@ +import type { + ArrangementLoopRange, + ArrangementTrack, + Clip, + ClipInstance, + MasterMixerState, + SampleMeta, + TrackMixerState, +} from "../model"; +import { + DEFAULT_ARRANGEMENT_LENGTH_BARS, + normalizeArrangementLengthBars, +} from "../model"; + +export const ACTIVE_PROJECT_ID = "active-project"; +export const DEFAULT_PROJECT_ID = "project-1"; +export const PROJECT_DOCUMENT_VERSION = 1; + +const DATABASE_NAME = "mini-daw-project-store"; +const DATABASE_VERSION = 2; +const PROJECT_STORE_NAME = "projects"; +const PROJECT_META_STORE_NAME = "projectMeta"; +const LEGACY_SAMPLE_BLOB_STORE_NAME = "sampleBlobs"; +const PROJECT_SAMPLE_BLOB_STORE_NAME = "projectSampleBlobs"; +const PROJECT_COLLECTION_ID = "project-collection"; +const SAMPLE_BLOB_KEY_SEPARATOR = "::"; + +export interface ProjectSummary { + createdAt: number; + id: string; + name: string; + updatedAt: number; +} + +export interface ProjectCollectionState { + activeProjectId: string; + projects: ProjectSummary[]; +} + +export interface PersistedProjectDocument { + arrangementLengthBars: number; + arrangementLoopRange: ArrangementLoopRange; + arrangementTracks: ArrangementTrack[]; + clipInstances: ClipInstance[]; + clips: Clip[]; + createdAt: number; + id: string; + masterMixerState: MasterMixerState; + name: string; + sampleMetas: SampleMeta[]; + savedAt: number; + tempoBpm: number; + trackMixerStates: TrackMixerState[]; + version: typeof PROJECT_DOCUMENT_VERSION; +} + +export interface CreatePersistedProjectDocumentOptions { + arrangementLengthBars: number; + arrangementLoopRange: ArrangementLoopRange; + arrangementTracks: readonly ArrangementTrack[]; + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + createdAt?: number; + id: string; + masterMixerState: MasterMixerState; + name: string; + sampleMetas: readonly SampleMeta[]; + savedAt?: number; + tempoBpm: number; + trackMixerStates: readonly TrackMixerState[]; +} + +export interface ImportedSampleBlobRecord { + blob: Blob; + fileName?: string; + id: string; + mimeType?: string; + projectId: string; + sampleId: string; + updatedAt: number; +} + +export interface SaveImportedSampleBlobOptions { + blob: Blob; + fileName?: string; + mimeType?: string; + projectId: string; + sampleId: string; + updatedAt?: number; +} + +export interface ProjectStore { + deleteProject(projectId: string): Promise; + loadActiveProject(): Promise; + loadImportedSampleBlob(projectId: string, sampleId: string): Promise; + loadProject(projectId: string): Promise; + loadProjectCollection(): Promise; + saveActiveProject(project: PersistedProjectDocument): Promise; + saveImportedSampleBlob(options: SaveImportedSampleBlobOptions): Promise; + saveProject(project: PersistedProjectDocument): Promise; + setActiveProjectId(projectId: string): Promise; +} + +export function createPersistedProjectDocument({ + arrangementLengthBars, + arrangementLoopRange, + arrangementTracks, + clipInstances, + clips, + createdAt, + id, + masterMixerState, + name, + sampleMetas, + savedAt = Date.now(), + tempoBpm, + trackMixerStates, +}: CreatePersistedProjectDocumentOptions): PersistedProjectDocument { + return { + arrangementLengthBars: normalizeArrangementLengthBars(arrangementLengthBars), + arrangementLoopRange, + arrangementTracks: [...arrangementTracks], + clipInstances: [...clipInstances], + clips: [...clips], + createdAt: createdAt ?? savedAt, + id, + masterMixerState: { ...masterMixerState }, + name, + sampleMetas: [...sampleMetas], + savedAt, + tempoBpm, + trackMixerStates: trackMixerStates.map((state) => ({ ...state })), + version: PROJECT_DOCUMENT_VERSION, + }; +} + +export function createProjectId(existingProjectIds: readonly string[]): string { + const existingProjectIdSet = new Set(existingProjectIds); + let nextProjectNumber = existingProjectIds.reduce((highestProjectNumber, id) => { + const match = /^project-(\d+)$/u.exec(id); + const projectNumber = match ? Number.parseInt(match[1] ?? "", 10) : 0; + + return Math.max( + highestProjectNumber, + Number.isNaN(projectNumber) ? 0 : projectNumber, + ); + }, 0) + 1; + let projectId = `project-${nextProjectNumber}`; + + while (existingProjectIdSet.has(projectId)) { + nextProjectNumber += 1; + projectId = `project-${nextProjectNumber}`; + } + + return projectId; +} + +export function createProjectSummary( + project: Pick, +): ProjectSummary { + return { + createdAt: project.createdAt, + id: project.id, + name: project.name, + updatedAt: project.savedAt, + }; +} + +export function createProjectCollectionState({ + activeProjectId, + projects, +}: { + activeProjectId: string; + projects: readonly ProjectSummary[]; +}): ProjectCollectionState { + const normalizedProjects = dedupeProjectSummaries(projects); + const activeProjectExists = normalizedProjects.some( + (project) => project.id === activeProjectId, + ); + + return { + activeProjectId: activeProjectExists + ? activeProjectId + : normalizedProjects[0]?.id ?? "", + projects: normalizedProjects, + }; +} + +export function upsertProjectSummary({ + collection, + project, +}: { + collection: ProjectCollectionState; + project: PersistedProjectDocument; +}): ProjectCollectionState { + const summary = createProjectSummary(project); + const hasProject = collection.projects.some( + (candidate) => candidate.id === project.id, + ); + const projects = hasProject + ? collection.projects.map((candidate) => + candidate.id === project.id ? summary : candidate, + ) + : [...collection.projects, summary]; + + return createProjectCollectionState({ + activeProjectId: collection.activeProjectId || project.id, + projects, + }); +} + +export function removeProjectSummary({ + collection, + projectId, +}: { + collection: ProjectCollectionState; + projectId: string; +}): ProjectCollectionState { + const projects = collection.projects.filter((project) => project.id !== projectId); + + return createProjectCollectionState({ + activeProjectId: + collection.activeProjectId === projectId + ? projects[0]?.id ?? "" + : collection.activeProjectId, + projects, + }); +} + +export function getImportedSampleIds( + project: Pick, +): string[] { + return project.sampleMetas + .filter((sampleMeta) => sampleMeta.source.kind === "imported") + .map((sampleMeta) => sampleMeta.id); +} + +export function migratePersistedProjectDocument( + value: unknown, +): PersistedProjectDocument | null { + if (!isRecord(value) || value.version !== PROJECT_DOCUMENT_VERSION) { + return null; + } + + if ( + typeof value.id !== "string" || + typeof value.name !== "string" || + typeof value.savedAt !== "number" || + typeof value.tempoBpm !== "number" || + !Array.isArray(value.arrangementTracks) || + !Array.isArray(value.clipInstances) || + !Array.isArray(value.clips) || + !Array.isArray(value.sampleMetas) || + !Array.isArray(value.trackMixerStates) || + !isRecord(value.arrangementLoopRange) || + !isRecord(value.masterMixerState) + ) { + return null; + } + + return { + ...(value as unknown as PersistedProjectDocument), + arrangementLengthBars: + typeof value.arrangementLengthBars === "number" + ? normalizeArrangementLengthBars(value.arrangementLengthBars) + : DEFAULT_ARRANGEMENT_LENGTH_BARS, + createdAt: + typeof value.createdAt === "number" ? value.createdAt : value.savedAt, + }; +} + +export function migrateProjectCollectionState( + value: unknown, +): ProjectCollectionState | null { + if ( + !isRecord(value) || + typeof value.activeProjectId !== "string" || + !Array.isArray(value.projects) + ) { + return null; + } + + const projects = value.projects.filter(isProjectSummary); + + if (projects.length !== value.projects.length) { + return null; + } + + return createProjectCollectionState({ + activeProjectId: value.activeProjectId, + projects, + }); +} + +export function createProjectSampleBlobId( + projectId: string, + sampleId: string, +): string { + return `${projectId}${SAMPLE_BLOB_KEY_SEPARATOR}${sampleId}`; +} + +export function createIndexedDbProjectStore( + databaseName = DATABASE_NAME, +): ProjectStore { + return new IndexedDbProjectStore(databaseName); +} + +class IndexedDbProjectStore implements ProjectStore { + private databasePromise: Promise | null = null; + + constructor(private readonly databaseName: string) {} + + async loadProjectCollection(): Promise { + const database = await this.getDatabase(); + const value = await getFromObjectStore( + database, + PROJECT_META_STORE_NAME, + PROJECT_COLLECTION_ID, + ); + const collection = migrateProjectCollectionState(value); + + if (collection) { + return collection; + } + + const legacyProject = await this.loadProject(ACTIVE_PROJECT_ID); + + if (!legacyProject) { + return createProjectCollectionState({ + activeProjectId: "", + projects: [], + }); + } + + const migratedProject: PersistedProjectDocument = { + ...legacyProject, + id: DEFAULT_PROJECT_ID, + }; + const migratedCollection = createProjectCollectionState({ + activeProjectId: migratedProject.id, + projects: [createProjectSummary(migratedProject)], + }); + + await putIntoObjectStore(database, PROJECT_STORE_NAME, migratedProject); + await migrateLegacySampleBlobs({ + database, + project: migratedProject, + }); + await putIntoObjectStore( + database, + PROJECT_META_STORE_NAME, + createStoredProjectCollection(migratedCollection), + ); + + return migratedCollection; + } + + async loadActiveProject(): Promise { + const collection = await this.loadProjectCollection(); + + if (collection.activeProjectId) { + const activeProject = await this.loadProject(collection.activeProjectId); + + if (activeProject) { + return activeProject; + } + } + + const fallbackProjectId = collection.projects[0]?.id; + + return fallbackProjectId ? this.loadProject(fallbackProjectId) : null; + } + + async loadProject(projectId: string): Promise { + const database = await this.getDatabase(); + const value = await getFromObjectStore(database, PROJECT_STORE_NAME, projectId); + + return migratePersistedProjectDocument(value); + } + + async saveActiveProject(project: PersistedProjectDocument): Promise { + await this.saveProject(project); + await this.setActiveProjectId(project.id); + } + + async saveProject(project: PersistedProjectDocument): Promise { + const database = await this.getDatabase(); + + await putIntoObjectStore(database, PROJECT_STORE_NAME, project); + + const collection = await this.loadProjectCollection(); + const nextCollection = upsertProjectSummary({ + collection, + project, + }); + + await putIntoObjectStore( + database, + PROJECT_META_STORE_NAME, + createStoredProjectCollection(nextCollection), + ); + } + + async setActiveProjectId(projectId: string): Promise { + const database = await this.getDatabase(); + const collection = await this.loadProjectCollection(); + const nextCollection = createProjectCollectionState({ + activeProjectId: projectId, + projects: collection.projects, + }); + + await putIntoObjectStore( + database, + PROJECT_META_STORE_NAME, + createStoredProjectCollection(nextCollection), + ); + + return nextCollection; + } + + async deleteProject(projectId: string): Promise { + const database = await this.getDatabase(); + const collection = await this.loadProjectCollection(); + const project = await this.loadProject(projectId); + const nextCollection = removeProjectSummary({ + collection, + projectId, + }); + + await deleteFromObjectStore(database, PROJECT_STORE_NAME, projectId); + await deleteProjectSampleBlobs(database, projectId); + + if ( + project && + (project.id === DEFAULT_PROJECT_ID || project.id === ACTIVE_PROJECT_ID) + ) { + await deleteLegacySampleBlobs({ + database, + project, + }); + } + + await putIntoObjectStore( + database, + PROJECT_META_STORE_NAME, + createStoredProjectCollection(nextCollection), + ); + + return nextCollection; + } + + async loadImportedSampleBlob( + projectId: string, + sampleId: string, + ): Promise { + const database = await this.getDatabase(); + const scopedValue = await getFromObjectStore( + database, + PROJECT_SAMPLE_BLOB_STORE_NAME, + createProjectSampleBlobId(projectId, sampleId), + ); + + if (isImportedSampleBlobRecord(scopedValue)) { + return scopedValue.blob; + } + + if (projectId !== DEFAULT_PROJECT_ID && projectId !== ACTIVE_PROJECT_ID) { + return null; + } + + const legacyValue = await getFromObjectStore( + database, + LEGACY_SAMPLE_BLOB_STORE_NAME, + sampleId, + ); + + return isLegacyImportedSampleBlobRecord(legacyValue) + ? legacyValue.blob + : null; + } + + async saveImportedSampleBlob({ + blob, + fileName, + mimeType, + projectId, + sampleId, + updatedAt = Date.now(), + }: SaveImportedSampleBlobOptions): Promise { + const database = await this.getDatabase(); + const record: ImportedSampleBlobRecord = { + blob, + fileName, + id: createProjectSampleBlobId(projectId, sampleId), + mimeType, + projectId, + sampleId, + updatedAt, + }; + + await putIntoObjectStore(database, PROJECT_SAMPLE_BLOB_STORE_NAME, record); + } + + private getDatabase(): Promise { + this.databasePromise ??= openDatabase(this.databaseName); + return this.databasePromise; + } +} + +interface StoredProjectCollectionState extends ProjectCollectionState { + id: typeof PROJECT_COLLECTION_ID; +} + +interface LegacyImportedSampleBlobRecord { + blob: Blob; + fileName?: string; + mimeType?: string; + sampleId: string; + updatedAt: number; +} + +function createStoredProjectCollection( + collection: ProjectCollectionState, +): StoredProjectCollectionState { + return { + ...collection, + id: PROJECT_COLLECTION_ID, + }; +} + +function dedupeProjectSummaries( + projects: readonly ProjectSummary[], +): ProjectSummary[] { + const projectsById = new Map(); + + for (const project of projects) { + projectsById.set(project.id, { ...project }); + } + + return Array.from(projectsById.values()).sort( + (left, right) => left.createdAt - right.createdAt, + ); +} + +async function migrateLegacySampleBlobs({ + database, + project, +}: { + database: IDBDatabase; + project: PersistedProjectDocument; +}): Promise { + const sampleIds = getImportedSampleIds(project); + + await Promise.all( + sampleIds.map(async (sampleId) => { + const legacyValue = await getFromObjectStore( + database, + LEGACY_SAMPLE_BLOB_STORE_NAME, + sampleId, + ); + + if (!isLegacyImportedSampleBlobRecord(legacyValue)) { + return; + } + + const record: ImportedSampleBlobRecord = { + blob: legacyValue.blob, + fileName: legacyValue.fileName, + id: createProjectSampleBlobId(project.id, sampleId), + mimeType: legacyValue.mimeType, + projectId: project.id, + sampleId, + updatedAt: legacyValue.updatedAt, + }; + + await putIntoObjectStore( + database, + PROJECT_SAMPLE_BLOB_STORE_NAME, + record, + ); + }), + ); +} + +async function deleteLegacySampleBlobs({ + database, + project, +}: { + database: IDBDatabase; + project: PersistedProjectDocument; +}): Promise { + const sampleIds = getImportedSampleIds(project); + + await Promise.all( + sampleIds.map((sampleId) => + deleteFromObjectStore(database, LEGACY_SAMPLE_BLOB_STORE_NAME, sampleId), + ), + ); +} + +async function deleteProjectSampleBlobs( + database: IDBDatabase, + projectId: string, +): Promise { + const records = await getAllFromObjectStore( + database, + PROJECT_SAMPLE_BLOB_STORE_NAME, + ); + const projectRecords = records.filter( + (record): record is ImportedSampleBlobRecord => + isImportedSampleBlobRecord(record) && record.projectId === projectId, + ); + + await Promise.all( + projectRecords.map((record) => + deleteFromObjectStore(database, PROJECT_SAMPLE_BLOB_STORE_NAME, record.id), + ), + ); +} + +function openDatabase(databaseName: string): Promise { + const indexedDb = window.indexedDB; + + if (!indexedDb) { + return Promise.reject(new Error("IndexedDB is not available in this browser.")); + } + + return new Promise((resolve, reject) => { + const request = indexedDb.open(databaseName, DATABASE_VERSION); + + request.onerror = () => { + reject(request.error ?? new Error("Failed to open IndexedDB.")); + }; + request.onupgradeneeded = () => { + const database = request.result; + + if (!database.objectStoreNames.contains(PROJECT_STORE_NAME)) { + database.createObjectStore(PROJECT_STORE_NAME, { keyPath: "id" }); + } + + if (!database.objectStoreNames.contains(PROJECT_META_STORE_NAME)) { + database.createObjectStore(PROJECT_META_STORE_NAME, { keyPath: "id" }); + } + + if (!database.objectStoreNames.contains(LEGACY_SAMPLE_BLOB_STORE_NAME)) { + database.createObjectStore(LEGACY_SAMPLE_BLOB_STORE_NAME, { + keyPath: "sampleId", + }); + } + + if (!database.objectStoreNames.contains(PROJECT_SAMPLE_BLOB_STORE_NAME)) { + database.createObjectStore(PROJECT_SAMPLE_BLOB_STORE_NAME, { + keyPath: "id", + }); + } + }; + request.onsuccess = () => { + resolve(request.result); + }; + }); +} + +function getFromObjectStore( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, +): Promise { + return withObjectStore(database, storeName, "readonly", (store) => + store.get(key), + ); +} + +function getAllFromObjectStore( + database: IDBDatabase, + storeName: string, +): Promise { + return withObjectStore(database, storeName, "readonly", (store) => + store.getAll(), + ); +} + +function putIntoObjectStore( + database: IDBDatabase, + storeName: string, + value: unknown, +): Promise { + return withObjectStore(database, storeName, "readwrite", (store) => + store.put(value), + ).then(() => undefined); +} + +function deleteFromObjectStore( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, +): Promise { + return withObjectStore(database, storeName, "readwrite", (store) => + store.delete(key), + ).then(() => undefined); +} + +function withObjectStore( + database: IDBDatabase, + storeName: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest, +): Promise { + return new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + const request = run(store); + + request.onerror = () => { + reject(request.error ?? new Error(`IndexedDB ${storeName} request failed.`)); + }; + request.onsuccess = () => { + resolve(request.result); + }; + transaction.onerror = () => { + reject( + transaction.error ?? new Error(`IndexedDB ${storeName} transaction failed.`), + ); + }; + }); +} + +function isProjectSummary(value: unknown): value is ProjectSummary { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.createdAt === "number" && + typeof value.updatedAt === "number" + ); +} + +function isImportedSampleBlobRecord( + value: unknown, +): value is ImportedSampleBlobRecord { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.projectId === "string" && + typeof value.sampleId === "string" && + value.blob instanceof Blob + ); +} + +function isLegacyImportedSampleBlobRecord( + value: unknown, +): value is LegacyImportedSampleBlobRecord { + return ( + isRecord(value) && + typeof value.sampleId === "string" && + value.blob instanceof Blob + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..a045332 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,66 @@ +@import "./tokens.css"; + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; + width: 100%; +} + +html { + min-width: 320px; + text-size-adjust: 100%; +} + +body { + background: var(--color-background); + color: var(--color-text); + font-family: var(--font-body); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body); + line-height: var(--line-height-body); + min-width: 320px; + overflow: hidden; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + border: 0; +} + +::selection { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.material-symbols-outlined { + direction: ltr; + display: inline-block; + font-family: "Material Symbols Outlined"; + font-feature-settings: "liga"; + font-size: 20px; + font-style: normal; + font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24; + font-weight: normal; + letter-spacing: normal; + line-height: 1; + text-transform: none; + white-space: nowrap; + word-wrap: normal; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..6e71ab0 --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,163 @@ +:root { + --primitive-color-gray-000: #ffffff; + --primitive-color-gray-050: #efffe3; + --primitive-color-gray-100: #e5e2e1; + --primitive-color-gray-100-alpha-54: rgb(229 226 225 / 54%); + --primitive-color-gray-200: #baccb0; + --primitive-color-gray-500: #85967c; + --primitive-color-gray-700: #3c4b35; + --primitive-color-gray-760: #393939; + --primitive-color-gray-780: #353535; + --primitive-color-gray-800: #2a2a2a; + --primitive-color-gray-850: #202020; + --primitive-color-gray-900: #1b1b1c; + --primitive-color-gray-950: #131313; + --primitive-color-gray-980: #0e0e0e; + --primitive-color-gray-980-alpha-64: rgb(14 14 14 / 64%); + --primitive-color-lime-300: #9ccc65; + --primitive-color-lime-300-alpha-48: rgb(156 204 101 / 48%); + --primitive-color-lime-700: #4a6b28; + --primitive-color-lime-950: #053900; + --primitive-color-teal-300: #80cbc4; + --primitive-color-teal-500: #26a69a; + --primitive-color-teal-950: #00363d; + --primitive-color-orange-300: #ffb77f; + --primitive-color-red-300: #ffb4ab; + --primitive-space-1: 4px; + --primitive-space-2: 8px; + --primitive-space-3: 12px; + --primitive-space-4: 16px; + --primitive-space-5: 20px; + --primitive-space-6: 24px; + --primitive-space-8: 32px; + --primitive-space-10: 40px; + --primitive-space-12: 48px; + --primitive-space-16: 64px; + --primitive-grid-fraction-4: 25%; + --primitive-grid-fraction-32: calc(100% / 32); + --primitive-radius-1: 2px; + --primitive-radius-2: 4px; + --primitive-radius-3: 8px; + --primitive-radius-4: 12px; + --primitive-border-width-2: 2px; + --primitive-font-size-1: 0.6875rem; + --primitive-font-size-2: 0.75rem; + --primitive-font-size-3: 0.8125rem; + --primitive-font-size-4: 0.875rem; + --primitive-font-size-5: 1rem; + --primitive-font-size-6: 1.25rem; + --primitive-font-weight-regular: 400; + --primitive-font-weight-medium: 500; + --primitive-font-weight-semibold: 600; + --primitive-font-weight-bold: 700; + --primitive-line-height-tight: 1.1; + --primitive-line-height-body: 1.45; + --primitive-z-index-0: 1; + --primitive-z-index-1: 1000; + + --color-background: var(--primitive-color-gray-950); + --color-surface: var(--primitive-color-gray-950); + --color-surface-dim: var(--primitive-color-gray-950); + --color-surface-container-lowest: var(--primitive-color-gray-980); + --color-surface-container-low: var(--primitive-color-gray-900); + --color-surface-container: var(--primitive-color-gray-850); + --color-surface-container-high: var(--primitive-color-gray-800); + --color-surface-container-highest: var(--primitive-color-gray-780); + --color-surface-variant: var(--primitive-color-gray-780); + --color-surface-bright: var(--primitive-color-gray-760); + --color-outline: var(--primitive-color-gray-500); + --color-outline-variant: var(--primitive-color-gray-700); + --color-primary: var(--primitive-color-lime-300); + --color-primary-strong: var(--primitive-color-gray-050); + --color-primary-container: var(--primitive-color-lime-700); + --color-on-primary: var(--primitive-color-lime-950); + --color-on-primary-container: var(--primitive-color-gray-000); + --color-secondary: var(--primitive-color-teal-500); + --color-secondary-fixed: var(--primitive-color-teal-300); + --color-on-secondary: var(--primitive-color-teal-950); + --color-tertiary: var(--primitive-color-orange-300); + --color-error: var(--primitive-color-red-300); + --color-text: var(--primitive-color-gray-100); + --color-text-muted: var(--primitive-color-gray-200); + --color-text-dim: rgb(186 204 176 / 70%); + --space-unit: var(--primitive-space-1); + --space-gutter: var(--primitive-space-2); + --space-panel: var(--primitive-space-3); + --space-section: var(--primitive-space-6); + --radius-xs: var(--primitive-radius-1); + --radius-sm: var(--primitive-radius-2); + --radius-md: var(--primitive-radius-3); + --radius-lg: var(--primitive-radius-4); + --font-body: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-size-label: var(--primitive-font-size-1); + --font-size-caption: var(--primitive-font-size-2); + --font-size-small: var(--primitive-font-size-3); + --font-size-body: var(--primitive-font-size-4); + --font-size-title: var(--primitive-font-size-6); + --font-weight-body: var(--primitive-font-weight-regular); + --font-weight-medium: var(--primitive-font-weight-medium); + --font-weight-label: var(--primitive-font-weight-semibold); + --font-weight-title: var(--primitive-font-weight-bold); + --line-height-tight: var(--primitive-line-height-tight); + --line-height-body: var(--primitive-line-height-body); + --letter-spacing-label: 0.08em; + --top-bar-height: var(--primitive-space-12); + --sidebar-width: 264px; + --sidebar-collapsed-width: var(--primitive-space-16); + --piano-key-width: var(--primitive-space-16); + --piano-row-height: var(--primitive-space-8); + --piano-ruler-height: var(--primitive-space-6); + --piano-roll-grid-height: 256px; + --piano-step-width: var(--primitive-grid-fraction-32); + --piano-beat-width: var(--primitive-grid-fraction-4); + --piano-grid-strong-line-width: var(--primitive-border-width-2); + --step-size: var(--primitive-space-8); + --transport-shadow: inset 0 -1px 0 rgb(255 255 255 / 6%); + --control-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), inset 0 -1px 0 rgb(0 0 0 / 45%); + --panel-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 4%); + --step-active-shadow: 0 0 12px rgb(156 204 101 / 32%), var(--control-inset-shadow); + --note-shadow: 0 0 12px rgb(38 166 154 / 24%); + --note-active-shadow: 0 0 16px rgb(156 204 101 / 28%), var(--note-shadow); + --color-grid-line: var(--color-outline-variant); + --color-grid-line-strong: var(--color-outline); + --color-note: var(--color-secondary); + --color-note-strong: var(--color-secondary-fixed); + --color-note-text: var(--color-on-secondary); + --color-key-white: var(--primitive-color-gray-100-alpha-54); + --color-key-black: var(--primitive-color-gray-980-alpha-64); + --color-key-label: var(--color-text-dim); + --z-index-editor-header: var(--primitive-z-index-0); + --z-index-editor-playhead: var(--primitive-z-index-0); + --z-index-popover: var(--primitive-z-index-1); + --color-playhead: var(--color-primary-strong); + --playhead-shadow: 0 0 14px var(--primitive-color-lime-300-alpha-48); + --playhead-width: var(--primitive-border-width-2); + + /* Compatibility aliases for older scaffold components. */ + --color-app-background: var(--color-background); + --color-text-primary: var(--color-text); + --color-text-secondary: var(--color-text-muted); + --color-text-disabled: var(--color-text-dim); + --color-border-subtle: var(--color-outline-variant); + --color-control-background: var(--color-surface-container); + --color-control-border: var(--color-outline-variant); + --color-control-accent: var(--color-primary); + --color-status-error: var(--color-error); + --color-grid-cell: var(--color-surface-container-low); + --color-grid-border: var(--color-outline-variant); + --space-page-padding: var(--space-section); + --space-page-padding-small: var(--space-panel); + --space-section-padding: var(--space-panel); + --space-layout-gap: var(--space-panel); + --space-panel-padding: var(--space-panel); + --space-content-gap: var(--space-gutter); + --space-label-gap: var(--space-unit); + --space-control-gap: var(--space-gutter); + --space-control-x: var(--space-panel); + --space-grid-gap: var(--space-unit); + --space-piano-grid-x: var(--primitive-space-10); + --space-piano-grid-y: var(--piano-row-height); + --radius-control: var(--radius-sm); + --radius-panel: var(--radius-md); + --radius-grid-cell: var(--radius-xs); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..89a5656 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,18 @@ +export { + DEFAULT_TEMPO_BPM, + MAX_TEMPO_BPM, + MIN_TEMPO_BPM, + clampTempoBpm, +} from "./tempo"; +export { + DEFAULT_PPQ, + TICKS_PER_4_4_BAR, + TICKS_PER_16_STEP, + TICKS_PER_BEAT, + audioTimeToTick, + getSecondsPerTick, + secondsToTicks, + tickToAudioTime, + ticksToSeconds, +} from "./tick-time"; +export type { Tick } from "./tick-time"; diff --git a/src/utils/tempo.ts b/src/utils/tempo.ts new file mode 100644 index 0000000..fb984f3 --- /dev/null +++ b/src/utils/tempo.ts @@ -0,0 +1,11 @@ +export const DEFAULT_TEMPO_BPM = 124; +export const MIN_TEMPO_BPM = 60; +export const MAX_TEMPO_BPM = 180; + +export function clampTempoBpm(tempoBpm: number): number { + if (!Number.isFinite(tempoBpm)) { + return DEFAULT_TEMPO_BPM; + } + + return Math.min(Math.max(Math.round(tempoBpm), MIN_TEMPO_BPM), MAX_TEMPO_BPM); +} diff --git a/src/utils/tick-time.ts b/src/utils/tick-time.ts new file mode 100644 index 0000000..6bf0815 --- /dev/null +++ b/src/utils/tick-time.ts @@ -0,0 +1,79 @@ +export type Tick = number; + +export const DEFAULT_PPQ = 480; +export const TICKS_PER_BEAT = DEFAULT_PPQ; +export const TICKS_PER_4_4_BAR = TICKS_PER_BEAT * 4; +export const TICKS_PER_16_STEP = TICKS_PER_4_4_BAR / 16; + +interface TickConversionOptions { + tempoBpm: number; + ppq?: number; +} + +interface TickToAudioTimeOptions extends TickConversionOptions { + audioStartTime: number; + startTick?: Tick; + tick: Tick; +} + +interface AudioTimeToTickOptions extends TickConversionOptions { + audioStartTime: number; + audioTime: number; + startTick?: Tick; +} + +export function getSecondsPerTick({ + tempoBpm, + ppq = DEFAULT_PPQ, +}: TickConversionOptions): number { + validateTempoBpm(tempoBpm); + validatePpq(ppq); + + return 60 / tempoBpm / ppq; +} + +export function ticksToSeconds( + ticks: Tick, + options: TickConversionOptions, +): number { + return ticks * getSecondsPerTick(options); +} + +export function secondsToTicks( + seconds: number, + options: TickConversionOptions, +): Tick { + return seconds / getSecondsPerTick(options); +} + +export function tickToAudioTime({ + audioStartTime, + startTick = 0, + tick, + tempoBpm, + ppq = DEFAULT_PPQ, +}: TickToAudioTimeOptions): number { + return audioStartTime + ticksToSeconds(tick - startTick, { tempoBpm, ppq }); +} + +export function audioTimeToTick({ + audioStartTime, + audioTime, + startTick = 0, + tempoBpm, + ppq = DEFAULT_PPQ, +}: AudioTimeToTickOptions): Tick { + return startTick + secondsToTicks(audioTime - audioStartTime, { tempoBpm, ppq }); +} + +function validateTempoBpm(tempoBpm: number): void { + if (!Number.isFinite(tempoBpm) || tempoBpm <= 0) { + throw new Error(`tempoBpm must be a positive finite number. Received ${tempoBpm}.`); + } +} + +function validatePpq(ppq: number): void { + if (!Number.isFinite(ppq) || ppq <= 0) { + throw new Error(`ppq must be a positive finite number. Received ${ppq}.`); + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/unit/audio/arrangement-events.test.ts b/tests/unit/audio/arrangement-events.test.ts new file mode 100644 index 0000000..1375cc3 --- /dev/null +++ b/tests/unit/audio/arrangement-events.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { expandClipInstancesForPlayback } from "../../../src/audio"; +import { + addNoteEvent, + addPitchedInstrumentToClip, + createEmptyHybridClip, + createImportedAudioClipDraft, + toggleDrumStep, + type ClipInstance, +} from "../../../src/model"; + +describe("arrangement playback event expansion", () => { + it("offsets hybrid clip drum and note events by clip instance start tick", () => { + const clipWithDrum = toggleDrumStep({ + clip: createEmptyHybridClip({ + id: "clip-1", + pitchedInstrumentIds: ["default-synth"], + }), + laneId: "kick", + stepIndex: 4, + }); + const clip = addNoteEvent({ + clip: clipWithDrum, + durationTicks: 240, + instrumentId: "default-synth", + midiNote: 60, + startTick: 960, + }); + const instance: ClipInstance = { + clipId: clip.id, + id: "instance-1", + lengthTicks: 1920, + startTick: 480, + trackId: "track-1", + }; + + expect( + expandClipInstancesForPlayback({ + clipInstances: [instance], + clips: [clip], + }), + ).toEqual({ + missingClipIds: [], + noteEvents: [ + { + durationTicks: 240, + gain: 0.8, + id: "instance-1:clip-1:note:default-synth:60:960", + instrumentId: "default-synth", + midiNote: 60, + startTick: 1440, + trackId: "track-1", + }, + ], + sampleEvents: [ + { + gain: 1, + id: "instance-1:clip-1:drum:kick:480", + sampleId: "fred-kick-1", + startTick: 960, + trackId: "track-1", + }, + ], + }); + }); + + it("schedules imported audio clips at their arrangement start tick", () => { + const { clip } = createImportedAudioClipDraft({ + clipId: "audio-clip-loop", + durationSeconds: 2, + fileName: "Loop.wav", + mimeType: "audio/wav", + sampleId: "imported-audio-loop", + }); + const instance: ClipInstance = { + clipId: clip.id, + id: "audio-instance-1", + lengthTicks: 1920, + sourceOffsetSeconds: 0.25, + startTick: 960, + trackId: "track-2", + }; + + expect( + expandClipInstancesForPlayback({ + clipInstances: [instance], + clips: [clip], + }).sampleEvents, + ).toEqual([ + { + durationTicks: 1920, + id: "audio-instance-1:audio", + sampleId: "imported-audio-loop", + sourceOffsetSeconds: 0.25, + startTick: 960, + trackId: "track-2", + }, + ]); + }); + + it("reports missing source clips without throwing", () => { + expect( + expandClipInstancesForPlayback({ + clipInstances: [ + { + clipId: "missing-clip", + id: "instance-1", + lengthTicks: 1920, + startTick: 0, + trackId: "track-1", + }, + ], + clips: [], + }), + ).toEqual({ + missingClipIds: ["missing-clip"], + noteEvents: [], + sampleEvents: [], + }); + }); + + it("does not expand hybrid events outside the clip instance length", () => { + const clip = addNoteEvent({ + clip: addPitchedInstrumentToClip({ + clip: createEmptyHybridClip({ id: "clip-1" }), + instrumentId: "default-synth", + }), + durationTicks: 120, + instrumentId: "default-synth", + midiNote: 60, + startTick: 960, + }); + + expect( + expandClipInstancesForPlayback({ + clipInstances: [ + { + clipId: clip.id, + id: "instance-1", + lengthTicks: 480, + startTick: 0, + trackId: "track-1", + }, + ], + clips: [clip], + }).noteEvents, + ).toEqual([]); + }); +}); diff --git a/tests/unit/audio/bundled-samples.test.ts b/tests/unit/audio/bundled-samples.test.ts new file mode 100644 index 0000000..7660947 --- /dev/null +++ b/tests/unit/audio/bundled-samples.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + BUNDLED_DRUM_SAMPLES, + BUNDLED_PIANO_SAMPLES, + BUNDLED_SAMPLES, + getBundledSampleDisplayName, +} from "../../../src/audio/bundled-samples"; + +describe("bundled samples", () => { + it("uses unique sample IDs", () => { + const ids = BUNDLED_SAMPLES.map((sample) => sample.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("points to bundled Fred wav files under the public samples directory", () => { + expect(BUNDLED_DRUM_SAMPLES).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "fred-kick-1", + name: "FRED KICK 1", + path: "/samples/drums/Fred_Kick_1.wav", + }), + expect.objectContaining({ + id: "fred-snare-1", + name: "FRED SNARE 1", + path: "/samples/drums/Fred_Snare_1.wav", + }), + expect.objectContaining({ + id: "fred-closed-hi-hat", + name: "FRED CLOSED HI-HAT", + path: "/samples/drums/Fred_Closed_Hi-Hat.wav", + }), + expect.objectContaining({ + id: "fred-open-hi-hat", + name: "FRED OPEN HI-HAT", + path: "/samples/drums/Fred_Open_Hi-Hat.wav", + }), + ]), + ); + + for (const sample of BUNDLED_DRUM_SAMPLES) { + expect(sample.path).toMatch(/^\/samples\/drums\/.+\.wav$/); + } + }); + + it("formats display names from wav file names", () => { + expect( + getBundledSampleDisplayName("/samples/drums/Fred_Closed_Hi-Hat.wav"), + ).toBe("FRED CLOSED HI-HAT"); + }); + + it("points to the bundled Iowa Piano C4-C5 wav files", () => { + expect(BUNDLED_PIANO_SAMPLES).toHaveLength(13); + expect(BUNDLED_PIANO_SAMPLES[0]).toEqual({ + id: "iowa-piano-c4", + name: "IOWA PIANO C4", + path: "/samples/pitched_instruments/Iowa_Piano/C4.wav", + }); + expect(BUNDLED_PIANO_SAMPLES.at(-1)).toEqual({ + id: "iowa-piano-c5", + name: "IOWA PIANO C5", + path: "/samples/pitched_instruments/Iowa_Piano/C5.wav", + }); + + for (const sample of BUNDLED_PIANO_SAMPLES) { + expect(sample.path).toMatch( + /^\/samples\/pitched_instruments\/Iowa_Piano\/.+\.wav$/, + ); + } + }); +}); diff --git a/tests/unit/audio/lookahead-scheduler.test.ts b/tests/unit/audio/lookahead-scheduler.test.ts new file mode 100644 index 0000000..e9cd31b --- /dev/null +++ b/tests/unit/audio/lookahead-scheduler.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + LookaheadScheduler, + collectScheduledEventsForWindow, + getLoopTickAtAbsoluteTick, + type ScheduledTickEvent, + type TickEvent, +} from "../../../src/audio/lookahead-scheduler"; + +interface TestEvent extends TickEvent { + label: string; +} + +const events: TestEvent[] = [ + { id: "start", label: "start", startTick: 0 }, + { id: "last-step", label: "last-step", startTick: 1800 }, + { id: "loop-end", label: "loop-end", startTick: 1920 }, +]; + +describe("collectScheduledEventsForWindow", () => { + it("schedules events inside the loop and excludes the loop end", () => { + const scheduledEvents = collectScheduledEventsForWindow({ + audioStartTime: 10, + events, + tempoBpm: 120, + windowEndTick: 1920, + windowStartTick: 0, + }); + + expect(scheduledEvents.map((scheduledEvent) => scheduledEvent.event.id)).toEqual([ + "start", + "last-step", + ]); + expect(scheduledEvents[0]?.audioTime).toBeCloseTo(10); + expect(scheduledEvents[1]?.audioTime).toBeCloseTo(11.875); + }); + + it("does not double-trigger loop boundary events across adjacent windows", () => { + const firstWindow = collectScheduledEventsForWindow({ + audioStartTime: 0, + events, + tempoBpm: 120, + windowEndTick: 1920, + windowStartTick: 0, + }); + const secondWindow = collectScheduledEventsForWindow({ + audioStartTime: 0, + events, + tempoBpm: 120, + windowEndTick: 3840, + windowStartTick: 1920, + }); + + expect(firstWindow.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 0, + 1800, + ]); + expect(secondWindow.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 1920, + 3720, + ]); + }); + + it("wraps absolute ticks to loop ticks", () => { + expect(getLoopTickAtAbsoluteTick({ absoluteTick: 0 })).toBe(0); + expect(getLoopTickAtAbsoluteTick({ absoluteTick: 1919 })).toBe(1919); + expect(getLoopTickAtAbsoluteTick({ absoluteTick: 1920 })).toBe(0); + expect(getLoopTickAtAbsoluteTick({ absoluteTick: 2040 })).toBe(120); + }); +}); + +describe("LookaheadScheduler", () => { + it("starts, schedules ahead, and stops", () => { + let audioTime = 0; + let intervalHandler: (() => void) | undefined; + let clearCount = 0; + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + clearIntervalFn: () => { + clearCount += 1; + intervalHandler = undefined; + }, + events: [{ id: "start", label: "start", startTick: 0 }], + getAudioTime: () => audioTime, + scheduleAheadTime: 0.2, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: (handler) => { + intervalHandler = handler; + return 1; + }, + tempoBpm: 120, + }); + + expect(scheduler.start().status).toBe("playing"); + expect(scheduledEvents.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 0, + ]); + + audioTime = 1.95; + const runInterval = intervalHandler; + + expect(runInterval).toBeDefined(); + runInterval?.(); + + expect(scheduledEvents.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 0, + 1920, + ]); + expect(scheduler.stop().status).toBe("stopped"); + expect(clearCount).toBe(1); + }); + + it("uses updated events for later scheduling windows", () => { + let audioTime = 0; + let intervalHandler: (() => void) | undefined; + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + clearIntervalFn: () => { + intervalHandler = undefined; + }, + events: [], + getAudioTime: () => audioTime, + scheduleAheadTime: 0.2, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: (handler) => { + intervalHandler = handler; + return 1; + }, + tempoBpm: 120, + }); + + scheduler.start(); + scheduler.setEvents([{ id: "start", label: "start", startTick: 0 }]); + + audioTime = 1.95; + intervalHandler?.(); + + expect(scheduledEvents.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 1920, + ]); + }); + + it("starts from a non-zero loop tick", () => { + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + events: [ + { id: "before-start", label: "before-start", startTick: 0 }, + { id: "at-start", label: "at-start", startTick: 480 }, + ], + getAudioTime: () => 10, + scheduleAheadTime: 0.1, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: () => 1, + tempoBpm: 120, + }); + + const snapshot = scheduler.start({ startTick: 480 }); + + expect(snapshot.currentTick).toBe(480); + expect( + scheduledEvents.map((scheduledEvent) => [ + scheduledEvent.event.id, + scheduledEvent.absoluteTick, + scheduledEvent.audioTime, + ]), + ).toEqual([["at-start", 480, 10]]); + }); + + it("pauses at the current loop tick and resumes from that tick", () => { + let audioTime = 0; + let intervalHandler: (() => void) | undefined; + let clearCount = 0; + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + clearIntervalFn: () => { + clearCount += 1; + intervalHandler = undefined; + }, + events: [ + { id: "first-beat", label: "first-beat", startTick: 480 }, + { id: "second-beat", label: "second-beat", startTick: 960 }, + ], + getAudioTime: () => audioTime, + scheduleAheadTime: 0.1, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: (handler) => { + intervalHandler = handler; + return 1; + }, + tempoBpm: 120, + }); + + scheduler.start(); + audioTime = 0.5; + + const pausedSnapshot = scheduler.pause(); + + expect(pausedSnapshot.status).toBe("paused"); + expect(pausedSnapshot.currentTick).toBe(480); + expect(clearCount).toBe(1); + expect(intervalHandler).toBeUndefined(); + + audioTime = 1; + scheduler.start({ startTick: pausedSnapshot.currentTick }); + + expect(scheduler.getSnapshot().currentTick).toBe(480); + expect(scheduledEvents.map((scheduledEvent) => scheduledEvent.absoluteTick)).toEqual([ + 480, + ]); + }); + + it("updates tempo for future scheduling windows while preserving current tick", () => { + let audioTime = 0; + let intervalHandler: (() => void) | undefined; + const scheduledEvents: ScheduledTickEvent[] = []; + const scheduler = new LookaheadScheduler({ + events: [{ id: "future-note", label: "future-note", startTick: 720 }], + getAudioTime: () => audioTime, + scheduleAheadTime: 0.1, + scheduleEvent: (scheduledEvent) => { + scheduledEvents.push(scheduledEvent); + }, + setIntervalFn: (handler) => { + intervalHandler = handler; + return 1; + }, + tempoBpm: 120, + }); + + scheduler.start(); + audioTime = 0.5; + + const tempoSnapshot = scheduler.setTempoBpm(60); + + expect(tempoSnapshot.status).toBe("playing"); + expect(tempoSnapshot.currentTick).toBe(480); + expect(tempoSnapshot.tempoBpm).toBe(60); + + audioTime = 1; + intervalHandler?.(); + + expect( + scheduledEvents.map((scheduledEvent) => [ + scheduledEvent.event.id, + scheduledEvent.absoluteTick, + scheduledEvent.audioTime, + scheduledEvent.tempoBpm, + ]), + ).toEqual([["future-note", 720, 1, 60]]); + expect(scheduler.getSnapshot().currentTick).toBe(720); + }); + + it("rejects invalid tempo updates", () => { + const scheduler = new LookaheadScheduler({ + events: [], + getAudioTime: () => 0, + scheduleEvent: () => {}, + tempoBpm: 120, + }); + + expect(() => scheduler.setTempoBpm(0)).toThrow( + "tempoBpm must be a positive finite number", + ); + }); +}); diff --git a/tests/unit/audio/sampler-sustain.test.ts b/tests/unit/audio/sampler-sustain.test.ts new file mode 100644 index 0000000..93a9fb0 --- /dev/null +++ b/tests/unit/audio/sampler-sustain.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveSamplerEnvelope, + resolveSamplerLoopRegion, + resolveSamplerPlaybackPlan, + resolveSamplerVoiceRelease, +} from "../../../src/audio"; +import type { SampleZone } from "../../../src/model"; + +const validSampleZone: SampleZone = { + midiNote: 60, + rootMidiNote: 60, + sampleId: "test-sample", + sampleStartSeconds: 0.2, + sustain: { + loopEndSeconds: 0.8, + loopStartSeconds: 0.4, + mode: "forward-loop", + }, +}; + +describe("resolveSamplerLoopRegion", () => { + it("uses valid forward-loop metadata when a note reaches the loop region", () => { + expect( + resolveSamplerLoopRegion({ + bufferDurationSeconds: 1, + noteDurationSeconds: 1.4, + sampleStartSeconds: 0.2, + sustain: validSampleZone.sustain, + }), + ).toEqual({ + loopEndSeconds: 0.8, + loopStartSeconds: 0.4, + }); + }); + + it("falls back to one-shot playback when a short note does not reach the loop", () => { + expect( + resolveSamplerLoopRegion({ + bufferDurationSeconds: 1, + noteDurationSeconds: 0.4, + sampleStartSeconds: 0.2, + sustain: validSampleZone.sustain, + }), + ).toBeNull(); + }); + + it("falls back for unsupported or disabled sustain modes", () => { + expect( + resolveSamplerLoopRegion({ + bufferDurationSeconds: 1, + noteDurationSeconds: 2, + sampleStartSeconds: 0.2, + sustain: { + loopEndSeconds: 0.8, + loopStartSeconds: 0.4, + mode: "none", + }, + }), + ).toBeNull(); + expect( + resolveSamplerLoopRegion({ + bufferDurationSeconds: 1, + noteDurationSeconds: 2, + sampleStartSeconds: 0.2, + sustain: { + crossfadeSeconds: 0.02, + loopEndSeconds: 0.8, + loopStartSeconds: 0.4, + mode: "crossfade-loop", + }, + }), + ).toBeNull(); + }); + + it("rejects invalid loop metadata instead of clamping it into a loop", () => { + const invalidLoopRegions = [ + { loopEndSeconds: 0.8, loopStartSeconds: 0.1 }, + { loopEndSeconds: 0.4, loopStartSeconds: 0.8 }, + { loopEndSeconds: 1.1, loopStartSeconds: 0.4 }, + { loopEndSeconds: Number.NaN, loopStartSeconds: 0.4 }, + { loopEndSeconds: 0.41, loopStartSeconds: 0.4 }, + ]; + + for (const invalidRegion of invalidLoopRegions) { + expect( + resolveSamplerLoopRegion({ + bufferDurationSeconds: 1, + noteDurationSeconds: 2, + sampleStartSeconds: 0.2, + sustain: { + ...invalidRegion, + mode: "forward-loop", + }, + }), + ).toBeNull(); + } + }); +}); + +describe("resolveSamplerPlaybackPlan", () => { + it("returns sample offset, envelope, and loop plan for valid sampler metadata", () => { + const playbackPlan = resolveSamplerPlaybackPlan({ + bufferDurationSeconds: 1, + noteDurationSeconds: 1.4, + sampleZone: { + ...validSampleZone, + envelope: { + attackSeconds: 0.02, + releaseSeconds: 0.12, + }, + }, + }); + + expect(playbackPlan).toMatchObject({ + envelope: { + attackSeconds: 0.02, + releaseSeconds: 0.12, + }, + sampleOffsetSeconds: 0.2, + sustainLoopRegion: { + loopEndSeconds: 0.8, + loopStartSeconds: 0.4, + }, + }); + expect(playbackPlan.sampleDurationSeconds).toBeUndefined(); + expect(playbackPlan.unloopedPlaybackDurationSeconds).toBeCloseTo(0.8); + }); + + it("uses sampleEndSeconds only when it is inside the decoded buffer", () => { + expect( + resolveSamplerPlaybackPlan({ + bufferDurationSeconds: 1, + noteDurationSeconds: 0.5, + sampleZone: { + ...validSampleZone, + sampleEndSeconds: 0.7, + sustain: { + mode: "none", + }, + }, + }).sampleDurationSeconds, + ).toBeCloseTo(0.5); + + expect( + resolveSamplerPlaybackPlan({ + bufferDurationSeconds: 1, + noteDurationSeconds: 0.5, + sampleZone: { + ...validSampleZone, + sampleEndSeconds: 1.2, + sustain: { + mode: "none", + }, + }, + }).sampleDurationSeconds, + ).toBeUndefined(); + }); + + it("reports the unlooped playback duration from the usable sample region", () => { + expect( + resolveSamplerPlaybackPlan({ + bufferDurationSeconds: 1.4, + noteDurationSeconds: 3, + playbackRate: 1, + sampleZone: { + ...validSampleZone, + sampleStartSeconds: 0.4, + sustain: { + mode: "none", + }, + }, + }).unloopedPlaybackDurationSeconds, + ).toBeCloseTo(1); + + expect( + resolveSamplerPlaybackPlan({ + bufferDurationSeconds: 1.4, + noteDurationSeconds: 3, + playbackRate: 2, + sampleZone: { + ...validSampleZone, + sampleEndSeconds: 1, + sampleStartSeconds: 0.4, + sustain: { + mode: "none", + }, + }, + }).unloopedPlaybackDurationSeconds, + ).toBeCloseTo(0.3); + }); +}); + +describe("resolveSamplerEnvelope", () => { + it("clamps attack and release for very short notes", () => { + expect( + resolveSamplerEnvelope({ + envelope: { + attackSeconds: 0.2, + releaseSeconds: 0.2, + }, + noteDurationSeconds: 0.08, + }), + ).toEqual({ + attackSeconds: 0.02, + releaseSeconds: 0.04, + }); + }); +}); + +describe("resolveSamplerVoiceRelease", () => { + it("returns a bounded release stop time for transport cleanup", () => { + expect( + resolveSamplerVoiceRelease({ + currentTime: 10, + releaseSeconds: 0.5, + }), + ).toEqual({ + releaseStartTime: 10, + stopTime: 10.2, + }); + }); +}); diff --git a/tests/unit/audio/wav-encoder.test.ts b/tests/unit/audio/wav-encoder.test.ts new file mode 100644 index 0000000..6ea830d --- /dev/null +++ b/tests/unit/audio/wav-encoder.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { encodePcm16WavArrayBuffer } from "../../../src/audio"; + +describe("wav encoder", () => { + it("encodes stereo float PCM as 16-bit little-endian WAV", () => { + const wavBuffer = encodePcm16WavArrayBuffer({ + channelData: [ + new Float32Array([0, 1]), + new Float32Array([-1, 0.5]), + ], + sampleRate: 44100, + }); + const view = new DataView(wavBuffer); + + expect(readAscii(view, 0, 4)).toBe("RIFF"); + expect(readAscii(view, 8, 4)).toBe("WAVE"); + expect(readAscii(view, 12, 4)).toBe("fmt "); + expect(view.getUint16(20, true)).toBe(1); + expect(view.getUint16(22, true)).toBe(2); + expect(view.getUint32(24, true)).toBe(44100); + expect(view.getUint16(34, true)).toBe(16); + expect(readAscii(view, 36, 4)).toBe("data"); + expect(view.getUint32(40, true)).toBe(8); + expect(view.getInt16(44, true)).toBe(0); + expect(view.getInt16(46, true)).toBe(-32768); + expect(view.getInt16(48, true)).toBe(32767); + expect(view.getInt16(50, true)).toBe(16384); + }); + + it("rejects mismatched channel lengths", () => { + expect(() => + encodePcm16WavArrayBuffer({ + channelData: [ + new Float32Array([0, 1]), + new Float32Array([0]), + ], + sampleRate: 44100, + }), + ).toThrow("All PCM channels must have the same frame count"); + }); +}); + +function readAscii(view: DataView, offset: number, length: number): string { + let value = ""; + + for (let index = 0; index < length; index += 1) { + value += String.fromCharCode(view.getUint8(offset + index)); + } + + return value; +} diff --git a/tests/unit/model/arrangement.test.ts b/tests/unit/model/arrangement.test.ts new file mode 100644 index 0000000..562e345 --- /dev/null +++ b/tests/unit/model/arrangement.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; + +import { + ARRANGEMENT_SNAP_TICKS, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, + createClipInstance, + createDefaultArrangementLoopRange, + createDefaultArrangementTracks, + createEmptyHybridClip, + createImportedAudioClipDraft, + deleteClipInstance, + getHybridClipLengthTicks, + getArrangementLengthTicks, + getArrangementLoopBoundaryIndex, + getArrangementLoopBoundaryIndexForLength, + getArrangementPlaybackEndTick, + getClipInstancesOutsideArrangementLength, + moveClipInstance, + normalizeArrangementLengthBars, + normalizeArrangementLoopRange, + removeClipInstancesOutsideArrangementLength, + snapArrangementTick, +} from "../../../src/model"; + +describe("arrangement model", () => { + it("creates default serializable arrangement tracks", () => { + expect(createDefaultArrangementTracks(3)).toEqual([ + { id: "track-1", name: "Track 1" }, + { id: "track-2", name: "Track 2" }, + { id: "track-3", name: "Track 3" }, + ]); + }); + + it("snaps arrangement ticks to the beat grid", () => { + expect(ARRANGEMENT_SNAP_TICKS).toBe(480); + expect(snapArrangementTick(0)).toBe(0); + expect(snapArrangementTick(241)).toBe(480); + expect(snapArrangementTick(-120)).toBe(0); + }); + + it("normalizes serializable arrangement length in bars", () => { + expect(DEFAULT_ARRANGEMENT_LENGTH_BARS).toBe(16); + expect(MIN_ARRANGEMENT_LENGTH_BARS).toBe(1); + expect(MAX_ARRANGEMENT_LENGTH_BARS).toBe(128); + expect(normalizeArrangementLengthBars(0)).toBe(1); + expect(normalizeArrangementLengthBars(12.4)).toBe(12); + expect(normalizeArrangementLengthBars(200)).toBe(128); + expect(getArrangementLengthTicks(12)).toBe(23040); + }); + + it("normalizes arrangement loop ranges to bar boundaries", () => { + expect(createDefaultArrangementLoopRange()).toEqual({ + endTick: 30720, + startTick: 0, + }); + expect( + normalizeArrangementLoopRange({ + endTick: 7700, + startTick: 1800, + }), + ).toEqual({ + endTick: 7680, + startTick: 1920, + }); + expect( + normalizeArrangementLoopRange({ + endTick: 1920, + startTick: 30720, + }), + ).toEqual({ + endTick: 30720, + startTick: 28800, + }); + expect(getArrangementLoopBoundaryIndex(3840)).toBe(2); + }); + + it("normalizes arrangement loop ranges within dynamic arrangement length", () => { + expect(createDefaultArrangementLoopRange(8)).toEqual({ + endTick: 15360, + startTick: 0, + }); + expect( + normalizeArrangementLoopRange( + { + endTick: 30720, + startTick: 14400, + }, + 8, + ), + ).toEqual({ + endTick: 15360, + startTick: 13440, + }); + expect(getArrangementLoopBoundaryIndexForLength(30720, 8)).toBe(8); + }); + + it("creates clip instances using hybrid clip length", () => { + const clip = createEmptyHybridClip({ + id: "clip-1", + lengthTicks: getHybridClipLengthTicks(2), + }); + const instance = createClipInstance({ + clip, + existingInstanceIds: [], + startTick: 510, + tempoBpm: 120, + trackId: "track-2", + }); + + expect(instance).toEqual({ + clipId: "clip-1", + id: "clip-instance-clip-1-track-2-480", + lengthTicks: 3840, + startTick: 480, + trackId: "track-2", + }); + }); + + it("creates audio clip instances with beat-rounded tick length", () => { + const { clip } = createImportedAudioClipDraft({ + clipId: "audio-clip-loop", + durationSeconds: 1.2, + fileName: "Loop.wav", + mimeType: "audio/wav", + sampleId: "imported-audio-loop", + }); + const instance = createClipInstance({ + clip, + existingInstanceIds: [], + startTick: 0, + tempoBpm: 120, + trackId: "track-1", + }); + + expect(instance.lengthTicks).toBe(1440); + }); + + it("moves and deletes clip instances without mutating source clips", () => { + const clip = createEmptyHybridClip({ id: "clip-1" }); + const instance = createClipInstance({ + clip, + existingInstanceIds: [], + startTick: 0, + tempoBpm: 120, + trackId: "track-1", + }); + const moved = moveClipInstance({ + instance, + startTick: 950, + trackId: "track-3", + }); + + expect(moved).toMatchObject({ + clipId: "clip-1", + startTick: 960, + trackId: "track-3", + }); + expect( + deleteClipInstance( + [ + instance, + { + ...moved, + id: "clip-instance-clip-1-track-3-960", + }, + ], + "clip-instance-clip-1-track-3-960", + ), + ).toEqual([instance]); + }); + + it("uses the visible arrangement length as the minimum playback end", () => { + const clip = createEmptyHybridClip({ id: "clip-1" }); + const instance = createClipInstance({ + clip, + existingInstanceIds: [], + startTick: 40000, + tempoBpm: 120, + trackId: "track-1", + }); + + expect(getArrangementPlaybackEndTick([])).toBe(30720); + expect(getArrangementPlaybackEndTick([instance])).toBe(41760); + }); + + it("finds and removes clip instances outside a shortened arrangement", () => { + const insideInstance = createClipInstance({ + clip: createEmptyHybridClip({ id: "clip-1" }), + existingInstanceIds: [], + startTick: 0, + tempoBpm: 120, + trackId: "track-1", + }); + const outsideInstance = createClipInstance({ + clip: createEmptyHybridClip({ + id: "clip-2", + lengthTicks: getHybridClipLengthTicks(2), + }), + existingInstanceIds: [insideInstance.id], + startTick: 3360, + tempoBpm: 120, + trackId: "track-1", + }); + const instances = [insideInstance, outsideInstance]; + + expect( + getClipInstancesOutsideArrangementLength({ + instances, + lengthBars: 2, + }).map((instance) => instance.id), + ).toEqual([outsideInstance.id]); + expect( + removeClipInstancesOutsideArrangementLength({ + instances, + lengthBars: 2, + }), + ).toEqual([insideInstance]); + }); +}); diff --git a/tests/unit/model/audio-clip.test.ts b/tests/unit/model/audio-clip.test.ts new file mode 100644 index 0000000..df2f2d6 --- /dev/null +++ b/tests/unit/model/audio-clip.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + createEmptyHybridClip, + createImportedAudioClipDraft, + createImportedAudioDisplayName, + createImportedAudioIds, + getClipDeleteConfirmationMessage, + toggleDrumStep, + validateImportedWavFile, +} from "../../../src/model"; + +describe("audio clip model", () => { + it("creates readable display names from imported file names", () => { + expect(createImportedAudioDisplayName("Vocal_Stem_01.wav")).toBe( + "Vocal Stem 01", + ); + expect(createImportedAudioDisplayName("kick-loop.WAV")).toBe("kick loop"); + }); + + it("validates WAV-like imported files", () => { + expect(() => + validateImportedWavFile({ + name: "loop.wav", + size: 128, + type: "audio/wav", + }), + ).not.toThrow(); + expect(() => + validateImportedWavFile({ + name: "loop.mp3", + size: 128, + type: "audio/mpeg", + }), + ).toThrow("Only WAV files can be imported."); + expect(() => + validateImportedWavFile({ + name: "empty.wav", + size: 0, + type: "audio/wav", + }), + ).toThrow("The selected WAV file is empty."); + }); + + it("creates stable unique IDs for imported audio clips and samples", () => { + expect( + createImportedAudioIds({ + existingClipIds: ["audio-clip-vocal-stem"], + existingSampleIds: ["imported-audio-vocal-stem"], + fileName: "Vocal_Stem.wav", + }), + ).toEqual({ + clipId: "audio-clip-vocal-stem-2", + sampleId: "imported-audio-vocal-stem-2", + }); + }); + + it("creates serializable audio clip and sample metadata", () => { + const draft = createImportedAudioClipDraft({ + clipId: "audio-clip-loop", + durationSeconds: 2.5, + fileName: "Loop.wav", + mimeType: "audio/wav", + sampleId: "imported-audio-loop", + }); + + expect(draft.clip).toEqual({ + durationSeconds: 2.5, + id: "audio-clip-loop", + kind: "audio", + mimeType: "audio/wav", + name: "Loop", + sampleId: "imported-audio-loop", + sourceFileName: "Loop.wav", + }); + expect(draft.sampleMeta).toEqual({ + durationSeconds: 2.5, + id: "imported-audio-loop", + name: "Loop", + source: { + fileName: "Loop.wav", + kind: "imported", + mimeType: "audio/wav", + }, + }); + }); + + it("does not require confirmation for empty unused hybrid clips", () => { + expect( + getClipDeleteConfirmationMessage({ + clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }), + }), + ).toBeNull(); + }); + + it("requires confirmation for hybrid clips with musical events", () => { + const clip = toggleDrumStep({ + clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }), + laneId: "kick", + stepIndex: 0, + }); + + expect(getClipDeleteConfirmationMessage({ clip })).toBe( + "Delete Clip 1 and its musical events?", + ); + }); + + it("requires confirmation for hybrid clips used in the arrangement", () => { + expect( + getClipDeleteConfirmationMessage({ + arrangementInstanceCount: 2, + clip: createEmptyHybridClip({ id: "clip-1", name: "Clip 1" }), + }), + ).toBe("Delete Clip 1? 2 arrangement placements will also be removed."); + }); + + it("includes arrangement removal in imported audio clip confirmation", () => { + const { clip } = createImportedAudioClipDraft({ + clipId: "audio-clip-loop", + durationSeconds: 2.5, + fileName: "Loop.wav", + mimeType: "audio/wav", + sampleId: "imported-audio-loop", + }); + + expect( + getClipDeleteConfirmationMessage({ + arrangementInstanceCount: 1, + clip, + }), + ).toBe( + "Delete imported audio clip Loop? 1 arrangement placement will also be removed.", + ); + }); +}); diff --git a/tests/unit/model/drum-clip.test.ts b/tests/unit/model/drum-clip.test.ts new file mode 100644 index 0000000..7e26809 --- /dev/null +++ b/tests/unit/model/drum-clip.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; + +import { + DRUM_LANES, + addNoteEvent, + addPitchedInstrumentToClip, + createEmptyHybridClip, + getDrumStepCount, + getDrumSubstepStartTick, + getDrumSubstepTicks, + getDrumStepStartTick, + getHybridClipLengthTicks, + hasHybridClipEventsOutsideLength, + hasNoteEventsForPitchedInstrument, + isDrumSubstepActive, + isDrumStepActive, + moveDrumLane, + removePitchedInstrumentFromClip, + renameClip, + toggleDrumSubstep, + toggleDrumStep, + updateDrumLaneSample, + updateDrumStepSubdivision, + updateHybridClipLength, +} from "../../../src/model"; + +describe("drum clip model", () => { + it("creates an empty 1-bar hybrid clip", () => { + expect(createEmptyHybridClip()).toMatchObject({ + drumEvents: [], + drumLanes: DRUM_LANES, + drumStepSubdivision: 1, + id: "clip-1", + lengthTicks: 1920, + name: "Clip 1", + noteEvents: [], + pitchedInstrumentIds: [], + }); + }); + + it("renames clips with non-empty names", () => { + const clip = createEmptyHybridClip(); + + expect(renameClip({ clip, name: " Verse A " }).name).toBe("Verse A"); + expect(renameClip({ clip, name: " " })).toBe(clip); + }); + + it("adds pitched instruments without duplicating existing membership", () => { + const clip = createEmptyHybridClip({ pitchedInstrumentIds: ["default-synth"] }); + const withPiano = addPitchedInstrumentToClip({ + clip, + instrumentId: "iowa-piano", + }); + + expect(withPiano.pitchedInstrumentIds).toEqual([ + "default-synth", + "iowa-piano", + ]); + expect( + addPitchedInstrumentToClip({ + clip: withPiano, + instrumentId: "iowa-piano", + }), + ).toBe(withPiano); + }); + + it("removes pitched instruments and optionally owned notes", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip({ + pitchedInstrumentIds: ["default-synth", "iowa-piano"], + }), + durationTicks: 120, + instrumentId: "iowa-piano", + midiNote: 60, + startTick: 0, + }); + + expect( + hasNoteEventsForPitchedInstrument({ + clip, + instrumentId: "iowa-piano", + }), + ).toBe(true); + + const keptNotes = removePitchedInstrumentFromClip({ + clip, + instrumentId: "iowa-piano", + }); + + expect(keptNotes.pitchedInstrumentIds).toEqual(["default-synth"]); + expect(keptNotes.noteEvents).toHaveLength(1); + + const removedNotes = removePitchedInstrumentFromClip({ + clip, + instrumentId: "iowa-piano", + removeOwnedNotes: true, + }); + + expect(removedNotes.pitchedInstrumentIds).toEqual(["default-synth"]); + expect(removedNotes.noteEvents).toEqual([]); + }); + + it("maps 16-step indices to 120-tick positions", () => { + expect(getDrumStepStartTick(0)).toBe(0); + expect(getDrumStepStartTick(4)).toBe(480); + expect(getDrumStepStartTick(15)).toBe(1800); + }); + + it("derives drum step counts from supported clip lengths", () => { + expect(getHybridClipLengthTicks(1)).toBe(1920); + expect(getHybridClipLengthTicks(2)).toBe(3840); + expect(getHybridClipLengthTicks(4)).toBe(7680); + expect(getDrumStepCount(getHybridClipLengthTicks(1))).toBe(16); + expect(getDrumStepCount(getHybridClipLengthTicks(2))).toBe(32); + expect(getDrumStepCount(getHybridClipLengthTicks(4))).toBe(64); + expect(getDrumStepStartTick(31, getHybridClipLengthTicks(2))).toBe(3720); + }); + + it("maps drum substeps to subdivision tick positions", () => { + expect(getDrumSubstepTicks(1)).toBe(120); + expect(getDrumSubstepTicks(2)).toBe(60); + expect(getDrumSubstepTicks(3)).toBe(40); + expect( + getDrumSubstepStartTick({ + stepIndex: 4, + subdivision: 2, + substepIndex: 1, + }), + ).toBe(540); + expect( + getDrumSubstepStartTick({ + stepIndex: 4, + subdivision: 3, + substepIndex: 2, + }), + ).toBe(560); + }); + + it("toggles a step into a serializable drum event and removes it", () => { + const clip = createEmptyHybridClip(); + const withKick = toggleDrumStep({ + clip, + laneId: "kick", + stepIndex: 4, + }); + + expect(withKick.drumEvents).toEqual([ + { + id: "clip-1:drum:kick:480", + laneId: "kick", + sampleId: "fred-kick-1", + startTick: 480, + velocity: 1, + }, + ]); + expect(isDrumStepActive(withKick.drumEvents, "kick", 4)).toBe(true); + + const withoutKick = toggleDrumStep({ + clip: withKick, + laneId: "kick", + stepIndex: 4, + }); + + expect(withoutKick.drumEvents).toEqual([]); + }); + + it("toggles subdivided drum events at exact tick positions", () => { + const clip = createEmptyHybridClip({ drumStepSubdivision: 3 }); + const withKick = toggleDrumSubstep({ + clip, + laneId: "kick", + stepIndex: 4, + substepIndex: 2, + }); + + expect(withKick.drumEvents).toEqual([ + { + id: "clip-1:drum:kick:560", + laneId: "kick", + sampleId: "fred-kick-1", + startTick: 560, + velocity: 1, + }, + ]); + expect( + isDrumSubstepActive({ + drumEvents: withKick.drumEvents, + laneId: "kick", + stepIndex: 4, + subdivision: 3, + substepIndex: 2, + }), + ).toBe(true); + + const withoutKick = toggleDrumSubstep({ + clip: withKick, + laneId: "kick", + stepIndex: 4, + substepIndex: 2, + }); + + expect(withoutKick.drumEvents).toEqual([]); + }); + + it("toggles drum events in longer clips", () => { + const clip = updateHybridClipLength({ + clip: createEmptyHybridClip(), + lengthTicks: getHybridClipLengthTicks(2), + }); + const withKick = toggleDrumSubstep({ + clip, + laneId: "kick", + stepIndex: 20, + substepIndex: 0, + }); + + expect(withKick.drumEvents[0]).toMatchObject({ + id: "clip-1:drum:kick:2400", + startTick: 2400, + }); + expect( + isDrumSubstepActive({ + clipLengthTicks: clip.lengthTicks, + drumEvents: withKick.drumEvents, + laneId: "kick", + stepIndex: 20, + subdivision: 1, + substepIndex: 0, + }), + ).toBe(true); + }); + + it("blocks shortening when events would fall outside the new length", () => { + const longClip = addNoteEvent({ + clip: updateHybridClipLength({ + clip: createEmptyHybridClip(), + lengthTicks: getHybridClipLengthTicks(2), + }), + durationTicks: 240, + midiNote: 60, + startTick: 1980, + }); + + expect( + hasHybridClipEventsOutsideLength({ + clip: longClip, + lengthTicks: getHybridClipLengthTicks(1), + }), + ).toBe(true); + expect(() => + updateHybridClipLength({ + clip: longClip, + lengthTicks: getHybridClipLengthTicks(1), + }), + ).toThrow("Cannot shorten clip"); + }); + + it("trims or removes events when shortening is explicitly allowed", () => { + const longClip = toggleDrumSubstep({ + clip: addNoteEvent({ + clip: addNoteEvent({ + clip: updateHybridClipLength({ + clip: createEmptyHybridClip(), + lengthTicks: getHybridClipLengthTicks(2), + }), + durationTicks: 240, + midiNote: 60, + startTick: 1860, + }), + durationTicks: 240, + midiNote: 62, + startTick: 1980, + }), + laneId: "kick", + stepIndex: 20, + substepIndex: 0, + }); + const shortenedClip = updateHybridClipLength({ + clip: longClip, + lengthTicks: getHybridClipLengthTicks(1), + trimEvents: true, + }); + + expect(shortenedClip.lengthTicks).toBe(1920); + expect(shortenedClip.drumEvents).toEqual([]); + expect(shortenedClip.noteEvents).toEqual([ + expect.objectContaining({ + durationTicks: 60, + midiNote: 60, + startTick: 1860, + }), + ]); + }); + + it("updates drum step subdivision without rewriting existing events", () => { + const clip = toggleDrumSubstep({ + clip: createEmptyHybridClip({ drumStepSubdivision: 2 }), + laneId: "snare", + stepIndex: 0, + substepIndex: 1, + }); + const updatedClip = updateDrumStepSubdivision({ + clip, + subdivision: 1, + }); + + expect(updatedClip.drumStepSubdivision).toBe(1); + expect(updatedClip.drumEvents).toEqual(clip.drumEvents); + expect(isDrumStepActive(updatedClip.drumEvents, "snare", 0)).toBe(false); + }); + + it("keeps drum events sorted by tick and lane order", () => { + const clip = createEmptyHybridClip(); + const withSnare = toggleDrumStep({ + clip, + laneId: "snare", + stepIndex: 1, + }); + const withKick = toggleDrumStep({ + clip: withSnare, + laneId: "kick", + stepIndex: 1, + }); + const withHat = toggleDrumStep({ + clip: withKick, + laneId: "closedHat", + stepIndex: 0, + }); + + expect( + withHat.drumEvents.map((event) => [event.startTick, event.laneId]), + ).toEqual([ + [0, "closedHat"], + [120, "kick"], + [120, "snare"], + ]); + }); + + it("defines the four initial drum lanes with Fred sample IDs and labels", () => { + expect(DRUM_LANES.map((lane) => [lane.id, lane.sampleId, lane.label])).toEqual([ + ["kick", "fred-kick-1", "FRED KICK 1"], + ["snare", "fred-snare-1", "FRED SNARE 1"], + ["closedHat", "fred-closed-hi-hat", "FRED CLOSED HI-HAT"], + ["openHat", "fred-open-hi-hat", "FRED OPEN HI-HAT"], + ]); + }); + + it("updates a lane sample and existing events for that lane", () => { + const clip = toggleDrumStep({ + clip: createEmptyHybridClip(), + laneId: "kick", + stepIndex: 0, + }); + const updatedClip = updateDrumLaneSample({ + clip, + label: "FRED KICK 2", + laneId: "kick", + sampleId: "fred-kick-2", + }); + + expect(updatedClip.drumLanes[0]).toMatchObject({ + id: "kick", + label: "FRED KICK 2", + sampleId: "fred-kick-2", + }); + expect(updatedClip.drumEvents[0]?.sampleId).toBe("fred-kick-2"); + }); + + it("moves drum lanes by target index", () => { + const clip = createEmptyHybridClip(); + const reorderedClip = moveDrumLane({ + clip, + laneId: "openHat", + targetIndex: 1, + }); + + expect(reorderedClip.drumLanes.map((lane) => lane.id)).toEqual([ + "kick", + "openHat", + "snare", + "closedHat", + ]); + }); +}); diff --git a/tests/unit/model/mixer.test.ts b/tests/unit/model/mixer.test.ts new file mode 100644 index 0000000..9618e7f --- /dev/null +++ b/tests/unit/model/mixer.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { + MIXER_DEFAULT_VOLUME_DB, + MIXER_MAX_VOLUME_DB, + MIXER_MIN_VOLUME_DB, + clampMixerVolumeDb, + createDefaultMasterMixerState, + createDefaultTrackMixerState, + createDefaultTrackMixerStates, + decibelsToLinearGain, + getTrackEffectiveGain, + getTrackMixerState, + isTrackMixerAudible, + updateMasterMixerState, + updateTrackMixerState, +} from "../../../src/model"; + +describe("mixer model", () => { + it("creates serializable default mixer states", () => { + expect(createDefaultTrackMixerState("track-1")).toEqual({ + muted: false, + solo: false, + trackId: "track-1", + volumeDb: MIXER_DEFAULT_VOLUME_DB, + }); + expect( + createDefaultTrackMixerStates([ + { id: "track-1", name: "Track 1" }, + { id: "track-2", name: "Track 2" }, + ]), + ).toEqual([ + { + muted: false, + solo: false, + trackId: "track-1", + volumeDb: 0, + }, + { + muted: false, + solo: false, + trackId: "track-2", + volumeDb: 0, + }, + ]); + expect(createDefaultMasterMixerState()).toEqual({ volumeDb: 0 }); + }); + + it("clamps fader values and converts decibels to linear gain", () => { + expect(clampMixerVolumeDb(-120)).toBe(MIXER_MIN_VOLUME_DB); + expect(clampMixerVolumeDb(12)).toBe(MIXER_MAX_VOLUME_DB); + expect(clampMixerVolumeDb(Number.NaN)).toBe(MIXER_DEFAULT_VOLUME_DB); + expect(decibelsToLinearGain(MIXER_MIN_VOLUME_DB)).toBe(0); + expect(decibelsToLinearGain(0)).toBe(1); + expect(decibelsToLinearGain(6)).toBeCloseTo(1.995, 3); + }); + + it("updates track and master mixer state without mutating existing state", () => { + const initialStates = [createDefaultTrackMixerState("track-1")]; + const nextStates = updateTrackMixerState(initialStates, "track-1", { + muted: true, + volumeDb: -12, + }); + + expect(initialStates[0]).toEqual(createDefaultTrackMixerState("track-1")); + expect(nextStates[0]).toMatchObject({ + muted: true, + trackId: "track-1", + volumeDb: -12, + }); + expect( + updateTrackMixerState([], "track-2", { + solo: true, + volumeDb: 99, + }), + ).toEqual([ + { + muted: false, + solo: true, + trackId: "track-2", + volumeDb: MIXER_MAX_VOLUME_DB, + }, + ]); + expect( + updateMasterMixerState(createDefaultMasterMixerState(), { volumeDb: -99 }), + ).toEqual({ volumeDb: MIXER_MIN_VOLUME_DB }); + }); + + it("uses deterministic mute and solo audibility rules", () => { + const trackOne = { + ...createDefaultTrackMixerState("track-1"), + solo: true, + }; + const trackTwo = createDefaultTrackMixerState("track-2"); + const mutedSolo = { + ...createDefaultTrackMixerState("track-3"), + muted: true, + solo: true, + }; + const allTrackStates = [trackOne, trackTwo, mutedSolo]; + + expect( + isTrackMixerAudible({ + allTrackStates, + trackState: trackOne, + }), + ).toBe(true); + expect( + isTrackMixerAudible({ + allTrackStates, + trackState: trackTwo, + }), + ).toBe(false); + expect( + isTrackMixerAudible({ + allTrackStates, + trackState: mutedSolo, + }), + ).toBe(false); + expect( + getTrackEffectiveGain({ + allTrackStates, + trackState: mutedSolo, + }), + ).toBe(0); + }); + + it("returns a default track mixer state for missing tracks", () => { + expect(getTrackMixerState([], "track-99")).toEqual( + createDefaultTrackMixerState("track-99"), + ); + }); +}); diff --git a/tests/unit/model/piano-roll.test.ts b/tests/unit/model/piano-roll.test.ts new file mode 100644 index 0000000..dc000f8 --- /dev/null +++ b/tests/unit/model/piano-roll.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { + PIANO_ROLL_COLUMN_COUNT, + PIANO_ROLL_PITCHES, + TICKS_PER_PIANO_ROLL_COLUMN, + addNoteEvent, + createEmptyHybridClip, + deleteNoteEvent, + getHybridClipLengthTicks, + getPianoRollColumnCount, + getPianoRollColumnStartTick, + getPianoRollPitchByMidiNote, + moveNoteEvent, + resizeNoteEvent, +} from "../../../src/model"; + +describe("piano roll model", () => { + it("defines a 32-column one-bar grid", () => { + expect(PIANO_ROLL_COLUMN_COUNT).toBe(32); + expect(TICKS_PER_PIANO_ROLL_COLUMN).toBe(60); + expect(getPianoRollColumnStartTick(0)).toBe(0); + expect(getPianoRollColumnStartTick(8)).toBe(480); + expect(getPianoRollColumnStartTick(31)).toBe(1860); + }); + + it("derives piano roll columns from clip length", () => { + expect(getPianoRollColumnCount(getHybridClipLengthTicks(1))).toBe(32); + expect(getPianoRollColumnCount(getHybridClipLengthTicks(2))).toBe(64); + expect(getPianoRollColumnCount(getHybridClipLengthTicks(4))).toBe(128); + expect(getPianoRollColumnStartTick(63, getHybridClipLengthTicks(2))).toBe( + 3780, + ); + }); + + it("defines the initial C4 through C5 piano pitch range", () => { + expect(PIANO_ROLL_PITCHES).toHaveLength(13); + expect(PIANO_ROLL_PITCHES[0]).toMatchObject({ + label: "C5", + midiNote: 72, + sampleId: "iowa-piano-c5", + }); + expect(PIANO_ROLL_PITCHES.at(-1)).toMatchObject({ + label: "C4", + midiNote: 60, + sampleId: "iowa-piano-c4", + }); + expect(getPianoRollPitchByMidiNote(61)).toMatchObject({ + label: "Db4", + sampleId: "iowa-piano-db4", + }); + }); + + it("adds a serializable note event", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 240, + midiNote: 60, + startTick: 120, + }); + + expect(clip.noteEvents).toEqual([ + { + durationTicks: 240, + id: "clip-1:note:default-synth:60:120", + instrumentId: "default-synth", + midiNote: 60, + startTick: 120, + velocity: 0.8, + }, + ]); + }); + + it("replaces an existing note at the same pitch and tick", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 60, + midiNote: 64, + startTick: 240, + }); + const replacedClip = addNoteEvent({ + clip, + durationTicks: 180, + midiNote: 64, + startTick: 240, + velocity: 0.5, + }); + + expect(replacedClip.noteEvents).toHaveLength(1); + expect(replacedClip.noteEvents[0]).toMatchObject({ + durationTicks: 180, + id: "clip-1:note:default-synth:64:240", + velocity: 0.5, + }); + }); + + it("allows different pitched instruments at the same pitch and tick", () => { + const synthClip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 120, + instrumentId: "default-synth", + midiNote: 60, + startTick: 0, + }); + const pianoClip = addNoteEvent({ + clip: synthClip, + durationTicks: 120, + instrumentId: "iowa-piano", + midiNote: 60, + startTick: 0, + }); + + expect(pianoClip.noteEvents).toHaveLength(2); + expect(pianoClip.noteEvents.map((event) => event.instrumentId)).toEqual([ + "default-synth", + "iowa-piano", + ]); + }); + + it("moves a note while preserving its duration and ID", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 120, + midiNote: 60, + startTick: 0, + }); + const movedClip = moveNoteEvent({ + clip, + midiNote: 72, + noteId: "clip-1:note:default-synth:60:0", + startTick: 1800, + }); + + expect(movedClip.noteEvents).toEqual([ + { + durationTicks: 120, + id: "clip-1:note:default-synth:60:0", + instrumentId: "default-synth", + midiNote: 72, + startTick: 1800, + velocity: 0.8, + }, + ]); + }); + + it("resizes and deletes notes", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 120, + midiNote: 67, + startTick: 600, + }); + const resizedClip = resizeNoteEvent({ + clip, + durationTicks: 360, + noteId: "clip-1:note:default-synth:67:600", + }); + const deletedClip = deleteNoteEvent({ + clip: resizedClip, + noteId: "clip-1:note:default-synth:67:600", + }); + + expect(resizedClip.noteEvents[0]?.durationTicks).toBe(360); + expect(deletedClip.noteEvents).toEqual([]); + }); + + it("clamps notes to the clip length", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip(), + durationTicks: 240, + midiNote: 60, + startTick: 1860, + }); + + expect(clip.noteEvents[0]).toMatchObject({ + durationTicks: 60, + startTick: 1860, + }); + }); + + it("allows notes in later bars of a longer clip", () => { + const clip = addNoteEvent({ + clip: createEmptyHybridClip({ + lengthTicks: getHybridClipLengthTicks(4), + }), + durationTicks: 240, + midiNote: 60, + startTick: 5760, + }); + + expect(clip.noteEvents[0]).toMatchObject({ + durationTicks: 240, + startTick: 5760, + }); + }); +}); diff --git a/tests/unit/model/pitched-instruments.test.ts b/tests/unit/model/pitched-instruments.test.ts new file mode 100644 index 0000000..14b59a9 --- /dev/null +++ b/tests/unit/model/pitched-instruments.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PITCHED_INSTRUMENT_ID, + DEFAULT_SYNTH_INSTRUMENT, + IOWA_PIANO_INSTRUMENT, + PITCHED_INSTRUMENTS, + getPitchedInstrument, + getSampleZoneForMidiNote, +} from "../../../src/model"; + +describe("pitched instruments", () => { + it("defines Default Synth and Iowa Piano as serializable metadata", () => { + expect(DEFAULT_PITCHED_INSTRUMENT_ID).toBe("default-synth"); + expect(PITCHED_INSTRUMENTS).toEqual([ + DEFAULT_SYNTH_INSTRUMENT, + IOWA_PIANO_INSTRUMENT, + ]); + expect(DEFAULT_SYNTH_INSTRUMENT).toEqual({ + id: "default-synth", + kind: "synth", + name: "Default Synth", + }); + expect(IOWA_PIANO_INSTRUMENT).toMatchObject({ + id: "iowa-piano", + kind: "sample", + name: "Iowa Piano", + }); + }); + + it("maps Iowa Piano MIDI notes to bundled C4 through C5 sample zones", () => { + expect(IOWA_PIANO_INSTRUMENT.zones).toHaveLength(13); + const c4Zone = getSampleZoneForMidiNote({ + instrument: IOWA_PIANO_INSTRUMENT, + midiNote: 60, + }); + const c5Zone = getSampleZoneForMidiNote({ + instrument: IOWA_PIANO_INSTRUMENT, + midiNote: 72, + }); + + expect(c4Zone).toMatchObject({ + envelope: { + attackSeconds: 0.012, + releaseSeconds: 0.09, + }, + midiNote: 60, + rootMidiNote: 60, + sampleStartSeconds: 0.525, + sampleId: "iowa-piano-c4", + sustain: { + mode: "forward-loop", + }, + }); + expect(c4Zone?.sustain?.loopStartSeconds).toBeCloseTo(3.4); + expect(c4Zone?.sustain?.loopEndSeconds).toBeCloseTo(4.8); + expect(c4Zone).not.toHaveProperty("loopEndSeconds"); + expect(c4Zone).not.toHaveProperty("loopStartSeconds"); + expect(c5Zone).toMatchObject({ + midiNote: 72, + rootMidiNote: 72, + sampleStartSeconds: 0.219, + sampleId: "iowa-piano-c5", + sustain: { + mode: "forward-loop", + }, + }); + expect(c5Zone?.sustain?.loopStartSeconds).toBeCloseTo(3.4); + expect(c5Zone?.sustain?.loopEndSeconds).toBeCloseTo(4.8); + expect(c5Zone).not.toHaveProperty("loopEndSeconds"); + expect(c5Zone).not.toHaveProperty("loopStartSeconds"); + expect( + getSampleZoneForMidiNote({ + instrument: IOWA_PIANO_INSTRUMENT, + midiNote: 59, + }), + ).toBeUndefined(); + }); + + it("looks up pitched instruments by ID", () => { + expect(getPitchedInstrument("default-synth")).toBe(DEFAULT_SYNTH_INSTRUMENT); + expect(getPitchedInstrument("iowa-piano")).toBe(IOWA_PIANO_INSTRUMENT); + }); + + it("keeps pitched instrument metadata JSON serializable", () => { + expect(JSON.parse(JSON.stringify(IOWA_PIANO_INSTRUMENT))).toEqual( + IOWA_PIANO_INSTRUMENT, + ); + }); +}); diff --git a/tests/unit/persistence/project-store.test.ts b/tests/unit/persistence/project-store.test.ts new file mode 100644 index 0000000..cb0a10d --- /dev/null +++ b/tests/unit/persistence/project-store.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; + +import { + ACTIVE_PROJECT_ID, + DEFAULT_PROJECT_ID, + PROJECT_DOCUMENT_VERSION, + createProjectCollectionState, + createProjectId, + createProjectSampleBlobId, + createPersistedProjectDocument, + getImportedSampleIds, + migrateProjectCollectionState, + migratePersistedProjectDocument, + removeProjectSummary, + upsertProjectSummary, +} from "../../../src/persistence"; +import { + createDefaultArrangementLoopRange, + createDefaultArrangementTracks, + createDefaultMasterMixerState, + createDefaultTrackMixerStates, + createEmptyHybridClip, + createImportedAudioClipDraft, +} from "../../../src/model"; + +describe("project persistence document helpers", () => { + it("creates a versioned serializable project document", () => { + const tracks = createDefaultArrangementTracks(2); + const document = createPersistedProjectDocument({ + arrangementLengthBars: 16, + arrangementLoopRange: createDefaultArrangementLoopRange(), + arrangementTracks: tracks, + clipInstances: [], + clips: [createEmptyHybridClip({ id: "clip-1" })], + createdAt: 100, + id: "project-7", + masterMixerState: createDefaultMasterMixerState(), + name: "Project 1", + sampleMetas: [], + savedAt: 123, + tempoBpm: 128, + trackMixerStates: createDefaultTrackMixerStates(tracks), + }); + + expect(document).toMatchObject({ + arrangementLengthBars: 16, + createdAt: 100, + id: "project-7", + name: "Project 1", + savedAt: 123, + tempoBpm: 128, + version: PROJECT_DOCUMENT_VERSION, + }); + expect(document.clips).toHaveLength(1); + expect(document.arrangementTracks).toHaveLength(2); + expect(document.trackMixerStates).toHaveLength(2); + }); + + it("collects imported sample IDs from serializable metadata", () => { + const { sampleMeta } = createImportedAudioClipDraft({ + clipId: "audio-clip-loop", + durationSeconds: 1, + fileName: "Loop.wav", + mimeType: "audio/wav", + sampleId: "imported-audio-loop", + }); + const document = createPersistedProjectDocument({ + arrangementLengthBars: 16, + arrangementLoopRange: createDefaultArrangementLoopRange(), + arrangementTracks: [], + clipInstances: [], + clips: [], + id: DEFAULT_PROJECT_ID, + masterMixerState: createDefaultMasterMixerState(), + name: "Project 1", + sampleMetas: [ + { + id: "fred-kick-1", + name: "FRED KICK 1", + source: { + kind: "bundled", + path: "/samples/drums/Fred_Kick_1.wav", + }, + }, + sampleMeta, + ], + savedAt: 123, + tempoBpm: 128, + trackMixerStates: [], + }); + + expect(getImportedSampleIds(document)).toEqual(["imported-audio-loop"]); + }); + + it("rejects unknown or malformed project document versions", () => { + expect(migratePersistedProjectDocument(null)).toBeNull(); + expect(migratePersistedProjectDocument({ version: 999 })).toBeNull(); + expect( + migratePersistedProjectDocument({ + id: ACTIVE_PROJECT_ID, + version: PROJECT_DOCUMENT_VERSION, + }), + ).toBeNull(); + }); + + it("defaults old persisted project documents to 16 arrangement bars", () => { + expect( + migratePersistedProjectDocument({ + arrangementLoopRange: createDefaultArrangementLoopRange(), + arrangementTracks: [], + clipInstances: [], + clips: [], + id: ACTIVE_PROJECT_ID, + masterMixerState: createDefaultMasterMixerState(), + name: "Project 1", + sampleMetas: [], + savedAt: 123, + tempoBpm: 128, + trackMixerStates: [], + version: PROJECT_DOCUMENT_VERSION, + }), + ).toMatchObject({ + arrangementLengthBars: 16, + createdAt: 123, + }); + }); + + it("creates stable project IDs after existing project IDs", () => { + expect(createProjectId([])).toBe("project-1"); + expect(createProjectId(["project-1", "project-3", "custom"])).toBe( + "project-4", + ); + }); + + it("normalizes project collection state and summaries", () => { + const collection = createProjectCollectionState({ + activeProjectId: "missing-project", + projects: [ + { + createdAt: 20, + id: "project-2", + name: "Project 2", + updatedAt: 30, + }, + { + createdAt: 10, + id: "project-1", + name: "Project 1", + updatedAt: 20, + }, + ], + }); + + expect(collection).toEqual({ + activeProjectId: "project-1", + projects: [ + { + createdAt: 10, + id: "project-1", + name: "Project 1", + updatedAt: 20, + }, + { + createdAt: 20, + id: "project-2", + name: "Project 2", + updatedAt: 30, + }, + ], + }); + }); + + it("upserts and removes project summaries", () => { + const tracks = createDefaultArrangementTracks(1); + const collection = createProjectCollectionState({ + activeProjectId: "", + projects: [], + }); + const project = createPersistedProjectDocument({ + arrangementLengthBars: 16, + arrangementLoopRange: createDefaultArrangementLoopRange(), + arrangementTracks: tracks, + clipInstances: [], + clips: [createEmptyHybridClip({ id: "clip-1" })], + createdAt: 100, + id: "project-1", + masterMixerState: createDefaultMasterMixerState(), + name: "Project 1", + sampleMetas: [], + savedAt: 200, + tempoBpm: 128, + trackMixerStates: createDefaultTrackMixerStates(tracks), + }); + const nextCollection = upsertProjectSummary({ collection, project }); + + expect(nextCollection).toMatchObject({ + activeProjectId: "project-1", + projects: [ + { + createdAt: 100, + id: "project-1", + name: "Project 1", + updatedAt: 200, + }, + ], + }); + expect( + removeProjectSummary({ + collection: nextCollection, + projectId: "project-1", + }), + ).toEqual({ + activeProjectId: "", + projects: [], + }); + }); + + it("migrates project collection state", () => { + expect( + migrateProjectCollectionState({ + activeProjectId: "project-2", + projects: [ + { + createdAt: 100, + id: "project-1", + name: "Project 1", + updatedAt: 120, + }, + { + createdAt: 200, + id: "project-2", + name: "Project 2", + updatedAt: 220, + }, + ], + }), + ).toMatchObject({ + activeProjectId: "project-2", + }); + expect( + migrateProjectCollectionState({ + activeProjectId: "project-1", + projects: [{ id: "project-1" }], + }), + ).toBeNull(); + }); + + it("creates scoped imported sample blob IDs", () => { + expect(createProjectSampleBlobId("project-1", "imported-audio-loop")).toBe( + "project-1::imported-audio-loop", + ); + }); +}); diff --git a/tests/unit/utils/tempo.test.ts b/tests/unit/utils/tempo.test.ts new file mode 100644 index 0000000..1568a40 --- /dev/null +++ b/tests/unit/utils/tempo.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TEMPO_BPM, + MAX_TEMPO_BPM, + MIN_TEMPO_BPM, + clampTempoBpm, +} from "../../../src/utils"; + +describe("tempo utilities", () => { + it("clamps tempo to the supported transport range", () => { + expect(clampTempoBpm(40)).toBe(MIN_TEMPO_BPM); + expect(clampTempoBpm(250)).toBe(MAX_TEMPO_BPM); + expect(clampTempoBpm(127.6)).toBe(128); + }); + + it("falls back to the default tempo for non-finite values", () => { + expect(clampTempoBpm(Number.NaN)).toBe(DEFAULT_TEMPO_BPM); + expect(clampTempoBpm(Number.POSITIVE_INFINITY)).toBe(DEFAULT_TEMPO_BPM); + }); +}); diff --git a/tests/unit/utils/tick-time.test.ts b/tests/unit/utils/tick-time.test.ts new file mode 100644 index 0000000..b77dacc --- /dev/null +++ b/tests/unit/utils/tick-time.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PPQ, + TICKS_PER_4_4_BAR, + TICKS_PER_16_STEP, + TICKS_PER_BEAT, + audioTimeToTick, + getSecondsPerTick, + secondsToTicks, + tickToAudioTime, + ticksToSeconds, +} from "../../../src/utils/tick-time"; + +describe("tick-time utilities", () => { + it("documents the default 4/4 tick constants", () => { + expect(DEFAULT_PPQ).toBe(480); + expect(TICKS_PER_BEAT).toBe(480); + expect(TICKS_PER_4_4_BAR).toBe(1920); + expect(TICKS_PER_16_STEP).toBe(120); + }); + + it("converts ticks to seconds at 120 bpm", () => { + expect(getSecondsPerTick({ tempoBpm: 120 })).toBeCloseTo(0.5 / 480); + expect(ticksToSeconds(480, { tempoBpm: 120 })).toBeCloseTo(0.5); + expect(ticksToSeconds(1920, { tempoBpm: 120 })).toBeCloseTo(2); + }); + + it("converts seconds back to ticks", () => { + expect(secondsToTicks(0.5, { tempoBpm: 120 })).toBeCloseTo(480); + expect(secondsToTicks(2, { tempoBpm: 120 })).toBeCloseTo(1920); + }); + + it("converts between ticks and audio context time", () => { + expect( + tickToAudioTime({ + audioStartTime: 10, + tick: 960, + tempoBpm: 120, + }), + ).toBeCloseTo(11); + expect( + audioTimeToTick({ + audioStartTime: 10, + audioTime: 11, + tempoBpm: 120, + }), + ).toBeCloseTo(960); + }); + + it("supports a non-zero transport start tick", () => { + expect( + tickToAudioTime({ + audioStartTime: 5, + startTick: 480, + tick: 960, + tempoBpm: 120, + }), + ).toBeCloseTo(5.5); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..a5701b6 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..01490aa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..12ec42a --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..006bfed --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["vitest"] + }, + "include": ["src", "tests"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..19bd385 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "node", + include: ["tests/**/*.{test,spec}.{ts,tsx}"], + }, +});