diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3945e2e..009c30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,46 +9,37 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12"] - + steps: - - uses: actions/checkout@v3 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} + - uses: actions/checkout@v4 - name: Install system audio libraries run: | sudo apt-get update sudo apt-get install -y libportaudio2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install pytest coverage - - - name: Lint with flake8 - run: | - pip install flake8 - flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics - flake8 src/ --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics - + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Sync dependencies (locked toolchain) + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Lint with ruff + run: uv run ruff check src tests + - name: Type check with mypy + run: uv run mypy src/audx + + - name: Test with pytest + coverage run: | - pip install mypy - mypy --ignore-missing-imports - - - name: Test with pytest - run: | - coverage run -m pytest -q - coverage report --fail-under=25 - coverage xml + uv run coverage run -m pytest -q + uv run coverage report --fail-under=40 + uv run coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: fail_ci_if_error: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..78ca7d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +# Publish to PyPI when a version tag is pushed (e.g. v0.3.0). +# Uses PyPI Trusted Publishing (OIDC) — no API token stored in the repo. +# One-time setup: on PyPI, add a trusted publisher for this repo + workflow. +# https://docs.pypi.org/trusted-publishers/ + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Build sdist + wheel + run: uv build + - name: Check metadata + run: uvx twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for trusted publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index b0fa4ad..ec85903 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,12 @@ __pycache__/ .coverage .coverage.* htmlcov/ +coverage.xml +dist/ +build/ +*.egg-info/ + +# marketing / remotion +marketing/remotion/node_modules/ +marketing/remotion/out/ +marketing/remotion/.remotion/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cadda72 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +All notable changes to audx are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.3.0] - 2026-06-21 + +### Added +- **Live MIDI jam** (`audx jam`): play the built-in synth kit in real time from a + MIDI controller or Push 2 — hit a pad, hear a sound instantly. Drums mode maps + General-MIDI notes to drum voices (and never leaves a pad silent); `--chromatic` + plays a melodic voice across the keys. New `audx/live.py` mapping module + tests, + and a guide at [docs/playing-live.md](docs/playing-live.md). +- **Push 2 pad LEDs**: audx now lights the Push 2 pads — `audx jam` auto-detects a + Push 2, paints the drum kit (one colour per voice) and flashes each pad as it's + struck. `audx push2 lights` lights the kit on its own for a quick check. + Implemented to Ableton's Push 2 MIDI spec (SysEx colour palette + note-on LEDs). +- Bundle the `python-rtmidi` MIDI backend so `midi`/`jam`/Push 2 work out of the box. +- **Built-in synth kit** (`audx/synth.py`): 20 pure-numpy procedurally-synthesised + voices with friendly aliases, per-voice tuning, velocity scaling and + deterministic (seeded) output. + - 14 drum/perc voices: `kick`, `sub`/`808`, `snare`, `clap`, `snap`, `hh`, `oh`, + `rim`, `tom`, `cowbell`, `perc`, `ride`, `crash`, `shaker`. + - 6 **melodic** voices: `bass`, `pluck`, `stab`, `keys` (alias `ep`), `saw`, + `sine` — pitched (band-limited sawtooth) and transposed via `tune`, so audx + does basslines, plucks and chord stabs, e.g. `bass e(5,16) | tune -7st`. + (`bass` is now its own voice, no longer an alias of `sub`.) +- **Song arrangements** (`audx/arrangement.py` `Song`/`Section`/`render_song`) plus + `audx song render ` and `audx song info `: build + multi-section tracks (intro/verse/drop/outro) from a JSON spec and render them + to a single WAV. +- **Live finger-drum pads** in the TUI: keys `w e r a s d f z x c` (and `u i o`) + trigger built-in synth voices on dedicated channels while a project is open. +- **Marketing assets**: an animated terminal demo GIF in the README + (`scripts/make-demo-gif.py`, Pillow-only) and a runnable Remotion promo video + project under `marketing/remotion/`. +- **Automated PyPI releases**: `.github/workflows/release.yml` publishes on a + `v*` tag via PyPI Trusted Publishing (OIDC, no stored token). +- **Zero-config sound**: the offline renderer falls back to the synth kit when a + sample of the same name isn't found, so `audx render "kick 4/4"` makes sound + with no sample library. `--sample` is now optional. +- `audx demo [out.wav]` — render a full multi-track beat with the synth kit in + one command. The flagship "try it in 10 seconds" experience. +- `audx synths` — list every built-in voice and its aliases. +- `SynthVoice` in the live engine, plus synth fallback in the real-time callback, + so the TUI makes sound without any samples loaded. + +### Changed +- **PortAudio is now optional for offline use.** `sounddevice` is imported lazily, + so the CLI and all offline features (render, export, demo, diff, project ops) + work on machines without the PortAudio system library. Only live real-time + playback requires it, with a clear install hint when missing. +- Type checking now covers the **entire package** (was 4 files) and passes clean. + +### Fixed +- `audx mix set gain -3` no longer errors on the negative dB value — the + command accepts dash-leading values as documented. + +### Quality +- Test suite expanded from 30 to 120+ tests; coverage raised from ~28% to ~43%. + CI coverage gate raised to 40%. + +## [0.2.0] + +### Added +- Terminal-native DAW core: pattern DSL, fixed-grid scheduler, 16-channel audio + engine with mute/gain/level meters, Textual TUI with mixer strips and tap tempo. +- Pattern DSL: `4/4`, `16x8`, Euclidean `e(k,n,rot)`, explicit `[1.0.1.0]` grids + and `x---` grids, with `vel`, `channel`, `swing`, `humanize`, `chance`, `gain`, + `pan` and `tune` pipe modifiers. +- Sample indexing/search, `.audx` JSON project save/load, offline WAV + stems + rendering, project diffing, cheap forking, hot-reload `watch`, read-only `serve` + dashboard. +- MIDI export to Standard MIDI File, MIDI clock-out, MIDI input recording. +- Pattern slots (A/B/C/D), vim-style macro registers, OS-keychain AI key store. +- Optional AI extras (librosa): text-to-pattern, sample similarity, auto-tagging, + groove extraction. +- Honest scaffolds: plugin discovery, Push 2 MIDI map, Heartmula bridge, + sadact-finisher bridge, minimal local `audxd` daemon, latency calibration. +- `audx doctor` diagnostics and CI across Python 3.10–3.12. + +[Unreleased]: https://github.com/totalaudiopromo/audx/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/totalaudiopromo/audx/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/totalaudiopromo/audx/releases/tag/v0.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dc42189 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to audx + +Thanks for hacking on audx. It's a terminal-native DAW that should feel like an +instrument, so the bar is high on both correctness and feel. + +## Setup + +```bash +uv sync # install runtime + dev deps into .venv +uv run audx demo loop.wav # smoke test: should render a beat with no setup +``` + +For live real-time audio (the TUI / `audx play`) you also need PortAudio: + +```bash +brew install portaudio # macOS +sudo apt install libportaudio2 # Debian/Ubuntu +``` + +Offline work (rendering, MIDI export, the synth kit, project ops) needs none of +this — it's pure Python + numpy. + +## Quality bar + +Every change must keep these green: + +```bash +uv run pytest -q # tests +uv run ruff check src tests # lint + import order +uv run mypy src/audx # types (whole package, must stay clean) +``` + +- **Tests are required** for new behaviour. Offline features should be testable + without an audio device — follow the `typer.testing.CliRunner` pattern in + `tests/test_cli.py` and the synth/render tests. +- **Types matter.** mypy checks the entire `src/audx` package. `warn_return_any` + is intentionally off (numpy stubs return `Any`); everything else is strict. +- **No silent failures.** Unfinished features should print an honest "not wired + up yet" message, never pretend or crash. + +## How the pieces fit + +- `pattern.py` — the DSL parser + deterministic step scheduler. A `Pattern` + parses its `dsl` string into `Step`s (instrument, beat, velocity, channel, …). +- `synth.py` — the built-in synth kit. Each voice is a pure-numpy function that + returns a mono `float32` one-shot. Add a voice by writing a `_name(sr, rng)` + renderer, registering it in `_RENDERERS`/`SYNTH_VOICES`, and adding aliases. +- `arrangement.py` — offline rendering. `render_arrangement` resolves each step + to audio via `_voice_audio`: a real sample if the library has one, otherwise + the synth kit, otherwise skipped. +- `engine.py` — the real-time engine. `sounddevice` is imported lazily inside + `start()`; never import it at module top. +- `cli.py` — the typer command surface. Keep help text accurate to behaviour. + +## Pull requests + +- Branch from `main`, keep PRs focused, write a clear description. +- Make sure `pytest`, `ruff` and `mypy` pass locally before pushing. +- Update `CHANGELOG.md` (`[Unreleased]`) and the docs when behaviour changes. + +## Philosophy + +> Code is the controller. Sound is the canvas. Terminal is the dimension. + +If a change makes audx feel more like an instrument and less like a program, +you're on the right track. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8540359 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chris Schofield + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PUBLISHING.md b/PUBLISHING.md index b3e11cf..2d9cef4 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -9,6 +9,37 @@ Both versions must keep version numbers in sync. --- +## Automated PyPI release (recommended) + +Releases are published to PyPI by `.github/workflows/release.yml` using +**PyPI Trusted Publishing (OIDC)** — no API token is stored in the repo. + +**One-time setup** (on PyPI, by the project owner): +1. Create the `audx` project on PyPI (or run the first release to TestPyPI). +2. Add a *trusted publisher*: PyPI → project → Settings → Publishing → + add GitHub, owner `totalaudiopromo`, repo `audx`, workflow `release.yml`, + environment `pypi`. See https://docs.pypi.org/trusted-publishers/. +3. In GitHub repo settings, create an environment named `pypi`. + +**Each release:** +```bash +# 1. bump version in pyproject.toml AND package.json (keep them in sync) +# 2. update CHANGELOG.md [Unreleased] -> the new version +git commit -am "release: audx v0.3.0" +git tag v0.3.0 +git push origin main --tags # the tag triggers the Release workflow +``` +The workflow builds the sdist + wheel with `uv build`, runs `twine check`, and +publishes to PyPI. Verify a clean install afterwards: `pip install audx`. + +To build/inspect locally without publishing: +```bash +uv build # -> dist/audx-.tar.gz + .whl +uvx twine check dist/* +``` + +--- + ## Prerequisites ### npm diff --git a/README.md b/README.md index 6c28b6a..d6817ca 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,183 @@ +
+ # audx -A terminal-native digital audio workstation for pattern sequencing and live-coded sample playback. Built for Chris's post-Ableton workflow: calm, local, hackable, no cloud dependency. +**Code your music. Own your sound.** + +A terminal-native digital audio workstation — pattern sequencing, a built-in +synth kit, stem mixing and live-coded playback, driven entirely from your +keyboard. Calm, local, hackable. No cloud, no mouse, no lock-in. + +[![CI](https://github.com/totalaudiopromo/audx/actions/workflows/ci.yml/badge.svg)](https://github.com/totalaudiopromo/audx/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.10%20|%203.11%20|%203.12-blue.svg)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -## Current reality +audx — terminal-native DAW with a live step sequencer -This is not a finished DAW yet. It is a playable core: +▶ the live synth sequencer · watch the 20s promo -- Pattern DSL: `kick 4/4`, `hh 16x8`, `x--- -x-- --x- ---x` -- Fixed-grid scheduler that works with real audio callback sizes -- 16-channel audio engine with mute, gain and level meters -- Textual TUI with mixer strips, transport and tap tempo -- Sample indexing and lookup from your local sample folder -- `.audx` JSON project save/load -- Diagnostics via `audx doctor` +
-Now included as honest scaffolds: offline single-pattern rendering, plugin discovery, Push 2 MIDI map output, Heartmula subprocess bridge, Sadact HTTP bridge, and a minimal local `audxd` daemon. Not yet done: true plugin hosting, full arrangement editor, Push 2 LED/display integration, and production-grade shared audio daemon. +--- -## Quick start +## Make a beat in 10 seconds + +No samples. No audio hardware. No config. ```bash -uv sync -uv run audx doctor -uv run audx pattern create main "kick 4/4" -uv run audx launch +pip install audx +audx demo loop.wav ``` -If you installed it as a tool/package, drop `uv run`: +audx demo session + +That writes a full multi-track beat — kick, sub, clap, hats, percussion — to +`loop.wav` using the **built-in synth kit**. Open it and you're listening to +audx. Then make your own: ```bash -audx doctor -audx launch +audx render "kick 4/4" -o kick.wav +audx render "cowbell e(5,16,2)" -o bell.wav +audx render "hh 16x8 | swing 12%" -o hats.wav ``` -## Commands +Every instrument name (`kick`, `snare`, `hh`, `clap`, `cowbell`, `808`, …) +synthesizes automatically when no matching sample is found — so you make sound +the moment you install, and drop in your own audio whenever you want. + +## What it is + +audx is a real instrument you play from the terminal: + +- **Pattern DSL** — `kick 4/4`, `hh 16x8`, `perc e(5,16,2)`, `clap [1.0.1.1]` +- **Built-in synth kit** — 20 procedurally-synthesised voices: drums, sub bass, plus melodic `bass`/`pluck`/`stab`/`keys`/`saw`/`sine`. Zero dependencies, zero samples. +- **Song arrangements** — multi-section tracks (intro/verse/drop/outro) rendered to one WAV +- **16-channel mixer** — gain, mute, pan and live level meters +- **Textual TUI** — mixer strips, transport, tap tempo, and live finger-drum pads +- **Offline rendering** — patterns and stems straight to WAV +- **MIDI** — export to Standard MIDI File, clock-out to gear, record MIDI in +- **Live-coding workflow** — `.audx` projects, slots, forks, hot-reload, diff + +## What it isn't (yet) + +Honest scaffolding, on the roadmap: true plugin **hosting** (discovery works +today), the Push 2 **screen** (pad **LEDs** work — see `audx jam`), a +production-grade shared audio daemon, and on-device voice control. These print +clear "not wired up yet" messages rather than pretending. + +## Install ```bash -audx init # Scaffold project folder (stems/, renders/, git init) -audx open [project] # Open TUI on a project file or folder -audx launch [project.audx] # Same as `open`, legacy spelling -audx pattern create "" # Parse/check a pattern -audx pattern list # List patterns in current process -audx track add "" -c 2 # Add a track to the in-process engine -audx track rm # Remove a track -audx mix set gain # Set channel gain -audx mix set mute on|off # Set channel mute -audx mute # Toggle channel mute -audx samples index ~/Samples # Index local samples -audx stems index ~/Samples # Alias matching spec §06 -audx stems search 909 kick # Fuzzy-search the sample index -audx render "kick 4/4" --sample k.wav # Offline WAV render -audx render ... --stems # Per-track stems render -audx render ... --variations 10 # Stochastic variations -audx diff a.audx b.audx # Human-readable project diff -audx finish project.audx --profile ukg # Render + master via sadact-finisher -audx fork project new-name # Cheap branching -audx save beat.audx # Save current in-process state -audx load beat.audx # Load and print project state -audx projects list # List saved project files -audx watch project.audx # Hot-reload .audx on save -audx serve --port 8080 # Read-only localhost dashboard -audx voice # Probe on-device voice control -audx rec --calibrate # Measure round-trip latency -audx export midi out.mid # Export patterns to Standard MIDI File -audx midi list # List MIDI inputs/outputs -audx midi out "Push 2" # Send MIDI clock to a device -audx midi rec name --bars 1 # Record incoming MIDI as a pattern -audx slot set project.audx A # Save current patterns into slot A -audx slot next project.audx B # Activate slot B -audx slot list project.audx # Show all four slots -audx macro record a "x j x j" # Store a macro in register a -audx macro replay a # Print register a -audx ai key sk-... # Store AI API key in OS keychain -audx plugins scan # Discover AU/VST/VST3 plugins only -audx push2 map # Print MIDI mapping scaffold -audx heartmula status # Check local heartlib bridge -audx sadact status # Check local sadact-finisher bridge -audx daemon serve # Run minimal local audxd state daemon -audx doctor # Diagnostics -audx version # Print version +# with uv (recommended for hacking on audx) +uv sync +uv run audx demo loop.wav + +# or as a tool / into your environment +pip install audx +audx demo loop.wav ``` -Flat backwards-compatible aliases also exist for early scripts: `pattern-create`, `patterns-list`, `samples-index`, `samples-list`, `projects-list`. +**Offline features need no system libraries.** Rendering, MIDI export, project +diffing and `audx demo` are pure Python + numpy. Only *live* real-time playback +(`audx play`, the live TUI) needs PortAudio: + +```bash +# macOS +brew install portaudio +# Debian / Ubuntu +sudo apt install libportaudio2 +``` ## Pattern DSL ```bash -audx pattern create kick "kick 4/4" # four on the floor -audx pattern create snare "snare 2/8" # beats 2 and 4 -audx pattern create hats "hh 16x8 | vel 0.45 | channel 2" # 8 hats over 16 steps -audx pattern create groove "x--- -x-- --x- ---x" # x/rest grid -audx pattern create perc "perc e(5,16,2)" # Euclidean, rotated -audx pattern create clap "clap [1.0.1.0.1.1.0.0]" # explicit grid +audx pattern create kick "kick 4/4" # four on the floor +audx pattern create snare "snare 2/8" # beats 2 and 4 +audx pattern create hats "hh 16x8 | vel 0.45 | channel 2" # 8 hats over 16 steps +audx pattern create perc "perc e(5,16,2)" # Euclidean, rotated +audx pattern create clap "clap [1.0.1.0.1.1.0.0]" # explicit grid +audx pattern create groove "x--- -x-- --x- ---x" # x/rest grid +``` + +**Base rhythms:** `inst 4/4` · `inst 16x8` · `inst e(hits,steps[,rotate])` · +`inst [1.0.1.0]` · `x--- -x--` grids. + +**Pipe modifiers** (`base | op arg | op arg …`): + +| op | meaning | example | +|----|---------|---------| +| `vel` / `velocity` | level 0.0–1.0 | `hh 16x8 \| vel 0.45` | +| `channel` / `ch` | mixer channel | `snare 2/8 \| ch 2` | +| `swing` | delay odd 16ths | `hh 16x8 \| swing 50%` | +| `humanize` | velocity jitter | `kick 4/4 \| humanize 8%` | +| `chance` | per-step probability | `perc e(5,16) \| chance 70%` | +| `gain` | ±dB on the track | `kick 4/4 \| gain -3db` | +| `pan` | L100..R100 or -1..1 | `clap 2/8 \| pan L50` | +| `tune` | ±semitones | `sub e(3,8) \| tune -5st` | + +See [docs/pattern-language.md](docs/pattern-language.md) and +[docs/synth-kit.md](docs/synth-kit.md) for the full reference. + +## Songs + +Arrange patterns into a multi-section track and render it to one WAV. A song is +a small JSON spec — sections of patterns, and the order they play: + +```json +{ + "bpm": 124, + "sections": { + "intro": {"patterns": ["hh 16x8 | swing 12%", "sub e(3,8) | tune -5st"], "bars": 4}, + "drop": {"patterns": ["kick 4/4", "clap 2/8", "bass e(5,16) | tune -7st"], "bars": 8}, + "outro": {"patterns": ["hh 16x8 | vel 0.4"], "bars": 4} + }, + "sequence": ["intro", "drop", "drop", "outro"] +} +``` + +```bash +audx song info track.json # resolved timeline: section → start bar +audx song render track.json -o track.wav +``` + +## Play it live + +Plug in a **MIDI controller or Push 2** and play the synth kit in real time — +hit a pad, hear a sound, no samples or setup: + +```bash +audx midi list # check your controller is detected +audx jam # drum pads → kick/snare/hat/... (every pad makes a sound) +audx jam --chromatic --voice bass # play a bassline across the keys ``` -Supported pipe operators: +No controller? Finger-drum from the **computer keyboard** in the TUI — keys +`w e r a s d f z x c` trigger kick/snare/clap/hats/etc on their own channels: -- `vel` / `velocity`: `0.0` to `1.0` -- `channel` / `ch`: mixer channel index -- `swing`: delays odd 16th steps. Example: `swing 50%` moves `0.25` beat events to `0.375`. -- `humanize`: percent jitter on velocity per fire (e.g. `humanize 8%`) -- `chance`: per-step trigger probability (`chance 70%`) -- `gain`: ±dB on the track (`gain -3db`) -- `pan`: `L100..R100` or `-1..1` (`pan L50`) -- `tune`: ±semitones (`tune -2st`) +```bash +audx open my-track +``` + +Full walkthrough (kids included): [docs/playing-live.md](docs/playing-live.md). + +## Command overview + +| Area | Commands | +|------|----------| +| **Make sound** | `demo` · `render` · `synths` · `jam` · `play` · `stop` | +| **Songs** | `song render ` · `song info ` | +| **Patterns / tracks** | `pattern create\|list\|delete` · `track add\|rm` | +| **Mixing** | `mix set gain\|mute` · `mute ` | +| **Samples / stems** | `samples index` · `stems search` · `samples list` | +| **Projects** | `init` · `open` · `save` · `load` · `projects list` · `fork` · `diff` · `watch` | +| **Slots & macros** | `slot set\|next\|list` · `macro record\|replay\|list` | +| **MIDI** | `export midi` · `midi out\|rec\|list` | +| **AI (optional extras)** | `ai pattern\|similar\|tag\|groove\|key` | +| **Bridges** | `plugins scan` · `push2 map\|lights` · `heartmula` · `sadact` · `daemon` | +| **Live dashboard** | `serve` | +| **Diagnostics** | `doctor` · `version` | + +Run `audx --help` or `audx --help` for details. ## Project files @@ -110,28 +185,36 @@ Supported pipe operators: ``` my-loop/ - project.audx # JSON: bpm, patterns, slots, mixer, finisher config - stems/ # WAV/AIFF source material - renders/ # rendered output (gitignored) - .git/ # optional, created by default + project.audx # JSON: bpm, patterns, slots, mixer, finisher config + stems/ # your WAV/AIFF source material (optional — synth kit is built in) + renders/ # rendered output (gitignored) + .git/ # optional, created by default ``` -The project file carries a `[finisher]` block that maps 1:1 to sadact-finisher -CLI flags (`profile`, `platform`, `loudness`, `tone`, `energy`, `reference`, -`use_stems`, `drums_up`, `bass_down`). `audx finish` uses these to drive the +Projects carry a `[finisher]` block that maps 1:1 to the `sadact-finisher` CLI +(`profile`, `platform`, `loudness`, `use_stems`, …); `audx finish` drives the mastering pass. ## Development ```bash uv sync -uv run pytest -q +uv run pytest -q # 120+ tests uv run ruff check src tests -uv run mypy src/audx +uv run mypy src/audx # clean across the whole package ``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for the quality bar and how the DSL and +synth kit are structured. + ## Philosophy -Code is the controller. Sound is the canvas. Terminal is the dimension. +> Code is the controller. Sound is the canvas. Terminal is the dimension. + +The bar for audx is not "does a test pass?" The bar is: can you open a terminal, +hit play, and feel like you're controlling a musical instrument rather than +debugging Python. + +## License -The bar for audx is not "does a test pass?". The bar is: can Chris open a terminal, hit play, and feel like he is controlling a musical instrument rather than debugging Python. +MIT © Chris Schofield — see [LICENSE](LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6ef92e5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# audx documentation + +- [Getting started](getting-started.md) — a guided first session +- [Playing live](playing-live.md) — MIDI controller / Push 2 jam guide (kid-friendly) +- [Pattern language](pattern-language.md) — the full DSL reference +- [Built-in synth kit](synth-kit.md) — every voice, aliases and the fallback model +- [Push 2 mapping](push2-mapping.md) — controller MIDI map scaffold + +For the project overview, install instructions and command list, see the +[top-level README](../README.md). diff --git a/docs/assets/audx-demo.gif b/docs/assets/audx-demo.gif new file mode 100644 index 0000000..741cd1f Binary files /dev/null and b/docs/assets/audx-demo.gif differ diff --git a/docs/assets/promo-poster.png b/docs/assets/promo-poster.png new file mode 100644 index 0000000..3b45f24 Binary files /dev/null and b/docs/assets/promo-poster.png differ diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..b0706d6 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,88 @@ +# Getting started + +A guided first session. By the end you'll have rendered a beat, built a few +patterns, and saved a project — all from the terminal, no samples needed. + +## 1. Install + +```bash +pip install audx # or: uv sync (to hack on audx itself) +audx doctor # sanity check: version, audio devices, paths +``` + +## 2. Hear audx immediately + +```bash +audx demo loop.wav +``` + +This renders a full multi-track beat (kick, sub, clap, hats, perc) with the +[built-in synth kit](synth-kit.md). Play `loop.wav` in anything. No samples, no +audio hardware, no config. + +## 3. Render your own patterns + +The [pattern language](pattern-language.md) is small. Try these: + +```bash +audx render "kick 4/4" -o kick.wav # four on the floor +audx render "snare 2/8" -o snare.wav # backbeat +audx render "hh 16x8 | swing 12% | vel 0.5" -o hats.wav # swung hats +audx render "perc e(5,16,2)" -o perc.wav # Euclidean groove +audx render "sub e(3,8) | tune -5st" -o bass.wav # tuned sub bass +``` + +See every available instrument: + +```bash +audx synths +``` + +## 4. Start a project + +```bash +audx init my-track --template techno +``` + +That scaffolds a folder with `project.audx`, `stems/` and `renders/`. Open the +TUI on it (needs PortAudio for live audio — see the README): + +```bash +audx open my-track +``` + +## 5. Live-coding workflow + +Edit `my-track/project.audx` in your editor while audx hot-reloads it: + +```bash +audx watch my-track/project.audx +``` + +Capture variations into slots and switch between them: + +```bash +audx slot set my-track/project.audx A +audx slot next my-track/project.audx B +audx slot list my-track/project.audx +``` + +Branch a project cheaply, or diff two snapshots: + +```bash +audx fork my-track my-track-dub +audx diff my-track/project.audx my-track-dub/project.audx +``` + +## 6. Export + +```bash +audx export midi out.mid # patterns → Standard MIDI File +audx midi out "Push 2" # clock-out to external gear +``` + +## Where to next + +- [pattern-language.md](pattern-language.md) — the full DSL reference +- [synth-kit.md](synth-kit.md) — every built-in voice +- `audx --help` — the complete command surface diff --git a/docs/playing-live.md b/docs/playing-live.md new file mode 100644 index 0000000..1aee0ba --- /dev/null +++ b/docs/playing-live.md @@ -0,0 +1,175 @@ +# Playing audx live with a MIDI controller or Push 2 + +A practical, kid-proof guide to plugging in a controller and making sound *now*. +No samples, no project, no setup — every pad makes a noise. + +> **TL;DR** +> ```bash +> audx midi list # check your controller shows up +> audx jam # hit the pads → drums! +> ``` + +--- + +## 1. What you need + +- audx installed (`pip install audx`). +- **PortAudio** for live sound (only needed for real-time play): + - macOS: `brew install portaudio` + - Linux: `sudo apt install libportaudio2` +- A USB MIDI controller **or** an Ableton Push 2. +- Headphones or speakers. (Headphones = fewer surprises with kids 🙂) + +Sanity check everything first: + +```bash +audx doctor # shows version + audio devices +audx midi list # should list your controller under "inputs" +``` + +If your controller shows up under `inputs:`, you're ready. + +--- + +## 2. Jam — pads make drums + +```bash +audx jam +``` + +Hit the pads. That's it. You'll see each hit print as it plays: + +``` + MIDI in: MPK Mini + ♪ jam mode: drums (hit some pads!) + Ctrl-C to stop. + 36 → kick ████ + 38 → snare ███ + 42 → hh ██ +``` + +- Pads follow the **General MIDI drum map** (the standard most controllers use): + kick, snare, hi-hat, clap, toms, cymbals, cowbell… +- **No pad is ever silent** — any pad that isn't a "standard" drum still triggers + a drum, so kids can mash the whole grid and always get sound. +- Harder hit = louder (velocity sensitive). + +Stop with **Ctrl-C**. + +### Pick a specific controller + +If you have more than one device, name it (any part of the name works): + +```bash +audx midi list # copy the name you want +audx jam --port "Push 2" +``` + +--- + +## 3. Jam — play melodies + +Switch to **chromatic** mode and the whole keyboard/grid plays one pitched voice: + +```bash +audx jam --chromatic # plays the 'keys' voice +audx jam --chromatic --voice bass # a bassline +audx jam --chromatic --voice pluck # plucky lead +``` + +Middle C plays the voice at its natural pitch; up/down the keys transpose it. +Melodic voices: `bass`, `pluck`, `stab`, `keys`, `saw`, `sine` +(see [the synth kit](synth-kit.md)). + +--- + +## 4. Push 2 notes + +Push 2 works as a standard MIDI controller over USB — **and audx lights its pads.** + +1. Plug it in (USB). For pads + LEDs, bus power is fine; the screen wants Push 2's + power supply. +2. Quick LED check (no audio needed): + ```bash + audx push2 lights # lights the drum kit on the pads; Ctrl-C to clear + ``` + You should see the bottom two rows light up — one colour per drum. +3. Play it: + ```bash + audx jam # auto-detects Push 2, lights the kit, flashes on hit + ``` + audx finds the Push 2 automatically (you don't need `--port`). Each pad in the + bottom two rows is a drum, lit in its own colour; hitting a pad flashes it white. +4. The whole 8×8 grid still plays in drums mode (unlit pads make sound too). Try + `--chromatic` to turn the grid into a pitched instrument (lights stay off in + chromatic mode). Disable lights with `audx jam --no-lights`. + +**Pad colours** (bottom row, left→right): kick · snare · clap · hh · oh · rim · tom, +then cowbell · sub · ride · crash · shaker on the next row up. + +> Notes: audx drives the pad **LEDs**; the Push 2 **screen** is still on the +> roadmap. If lights don't appear, make sure no other app (Ableton/Live) has +> grabbed the Push 2 — pick its **User** port. Send Push 2 a tempo clock with +> `audx midi out "Push 2" --bpm 120`. + +--- + +## 5. No controller? Play from the keyboard + +Open the TUI and use the computer keyboard as drum pads — no MIDI needed: + +```bash +audx open +``` + +Pad keys: `w` kick · `e` snare · `r` clap · `a` hh · `s` oh · `d` rim · `f` tom · +`z` cowbell · `x` perc · `c` sub · `u` ride · `i` crash · `o` shaker. + +--- + +## 6. Keep what you played + +Record a controller performance into a pattern, then render it to a WAV you can +keep or send to grandma: + +```bash +audx midi rec jam1 --bars 2 # records 2 bars from your controller +audx render "kick 4/4" -o keep.wav # or render any pattern to a file +``` + +Build a whole track from sections with [songs](../README.md#songs): + +```bash +audx song render track.json -o track.wav +``` + +--- + +## Troubleshooting + +**No sound when I hit pads** +- Is PortAudio installed? (`brew install portaudio` / `apt install libportaudio2`) +- Check your output device + volume; try headphones. +- Run `audx doctor` — it lists detected audio devices. + +**My controller isn't in `audx midi list`** +- Re-seat the USB cable; some controllers need a moment after plugging in. +- Close other music apps (Ableton, GarageBand) — they can grab the port + exclusively so audx can't open it. +- On Push 2, pick the first/"Live" port name. + +**There's a delay between hitting a pad and the sound** +- Some latency is normal. Measure and note your round-trip: + `audx rec --calibrate`. +- Close other heavy apps; use wired headphones rather than Bluetooth (Bluetooth + adds noticeable lag). + +**It played a weird drum for that pad** +- In drums mode, non-standard pads fall back to a drum based on the note, so the + mapping is "always something" rather than "always the same". Use a controller's + drum/GM mode for the classic layout, or `--chromatic` for pitched play. + +--- + +Have fun. The whole point of audx is that you can plug in, hit a pad, and be +making music in ten seconds — kids very much included. diff --git a/docs/synth-kit.md b/docs/synth-kit.md new file mode 100644 index 0000000..c201f09 --- /dev/null +++ b/docs/synth-kit.md @@ -0,0 +1,100 @@ +# The built-in synth kit + +audx ships a tiny procedural drum machine **and** a handful of pitched melodic +voices so it makes sound with **zero setup** — no sample library required. It's +pure `numpy` (no audio backend, no scipy), so it imports anywhere and renders +arrangements offline. + +## How fallback works + +When the renderer (or the live engine) resolves a pattern step, it looks for +audio in this order: + +1. A **real sample** in your library whose name matches the instrument + (`kick.wav`, `snare.aif`, …). Your audio always wins. +2. Otherwise, a **built-in synth voice** of that name (or one of its aliases). +3. Otherwise the step is **skipped** silently — no crash. + +So `audx render "kick 4/4"` synthesises a kick today, and the day you drop a +`kick.wav` into your stems folder it uses that instead. Nothing else changes. + +## Voices + +| Voice | Aliases | Character | +|-------|---------|-----------| +| `kick` | `bd`, `bassdrum` | Punchy 115→48 Hz pitch-swept kick with a transient click | +| `sub` | `808`, `bass` | Long sub-bass with gentle saturation (an 808-ish tone) | +| `snare` | `sd`, `sn` | Tonal body + high-passed noise | +| `clap` | `cp`, `clp` | Three fast noise bursts + a longer tail | +| `snap` | — | Tight finger-snap (noise + short tone) | +| `hh` | `hat`, `hats`, `hihat`, `ch`, `closedhat` | Short high-passed noise (closed hat) | +| `oh` | `openhat`, `ohh` | Longer high-passed noise (open hat) | +| `rim` | `rs`, `rimshot`, `clave` | Bright metallic ping | +| `tom` | `lt`, `mt`, `ht`, `floortom` | Pitched, decaying drum | +| `cowbell` | `cb`, `bell` | Two detuned square tones (the 808 cowbell trick) | +| `perc` | — | 2-operator FM metallic stab | +| `ride` | `rd` | Shimmering struck-cymbal partials | +| `crash` | `cy`, `cym` | Long high-passed noise wash | +| `shaker` | `shk`, `maraca` | Short filtered-noise shaker | +| `bass` | — | Punchy synth bass: band-limited saw + sub sine, lowpassed (~C2) | +| `pluck` | — | Short plucked saw with a downward lowpass sweep (~C3) | +| `stab` | — | Bright detuned-saw minor-chord stab (root + min 3rd + 5th, ~C3) | +| `keys` | `ep` | Soft electric-piano-ish tone: sine + harmonics, gentle attack (~C3) | +| `saw` | — | Raw band-limited sawtooth building block (~C3) | +| `sine` | — | Pure sine tone building block (~C3) | + +### Melodic voices + +The last six voices above are **pitched**, so audx can do basslines, stabs and +chords — not just beats. Each renders at a sensible fixed base pitch (`bass` near +**C2 ≈ 65 Hz**, the rest near **C3 ≈ 131 Hz**; `stab` is a minor triad rooted at +C3). There is no note-name syntax in the DSL — you set pitch entirely with the +existing `tune` modifier, which transposes by resampling: + +```bash +audx render "bass e(3,8) | tune -5st" -o bassline.wav # down a fourth +audx render "stab e(3,8,2) | tune 5st" -o stabs.wav # up a fourth +audx render "keys 4/4 | tune 7st | vel 0.6" # up a fifth, softer +``` + +Their sawtooth content is band-limited (additive synthesis of a bounded set of +harmonics), so repitching them up or down stays clean instead of aliasing harshly. + +List them any time: + +```bash +audx synths +``` + +## Using voices + +Any voice name works as an instrument in the DSL: + +```bash +audx render "cowbell e(5,16,2)" -o bell.wav +audx render "sub e(3,8) | tune -5st" -o bass.wav +audx render "hh 16x8 | swing 12% | vel 0.5" +``` + +Modifiers apply to synth voices exactly as they do to samples: + +- `vel` scales amplitude. +- `tune` repitches the voice (negative = lower/longer). Implemented by resampling. +- `pan`, `gain`, `swing`, `humanize`, `chance` behave as in the + [pattern language](pattern-language.md). + +## Programmatic API + +```python +from audx.synth import synth_voice, is_synth_voice, list_voices + +buf = synth_voice("kick", 44100, velocity=0.9, tune_semitones=-2.0) +# -> mono float32 numpy array in [-1, 1] + +is_synth_voice("808") # True (alias of 'sub') +list_voices() # ['kick', 'sub', 'snare', ...] +``` + +Output is deterministic by default (seeded RNG), so renders are reproducible. +Pass `seed=None` to vary the noise components, or a different integer `seed` per +hit for natural variation. diff --git a/marketing/build-beat.py b/marketing/build-beat.py new file mode 100644 index 0000000..5c411e1 --- /dev/null +++ b/marketing/build-beat.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Build the promo beat + a frame-accurate data track for the Remotion video. + +Renders an actual audx beat (built-in synth kit) to WAV and emits a JSON sidecar +describing it frame-by-frame at the video's fps, so the motion graphics can be +driven by the *real* audio: step-sequencer hits, per-track meter envelopes, and a +master waveform. The video's visuals therefore literally move to audx's own sound. + +Run: uv run python marketing/build-beat.py +""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import soundfile as sf + +from audx.arrangement import Arrangement, render_arrangement +from audx.pattern import Pattern +from audx.sampler import SampleLibrary + +FPS = 30 +BPM = 124.0 +BARS = 12 # ~23s, comfortably covers a 20s (600-frame) video +SR = 44100 +STEPS_PER_BAR = 16 + +# (name, dsl, hex colour) — these are the SAME patterns we render to audio, +# so the grid and the sound are guaranteed to agree. +# Monochrome by design — every track is the same warm off-white; the only +# colour in the piece is a single amber accent applied to the play position. +_INK = "#ececea" +TRACKS = [ + ("kick", "kick 4/4", _INK), + ("clap", "clap 2/8", _INK), + ("hats", "hh 16x8 | swing 10%", _INK), + ("oh", "oh [0.0.1.0.0.0.1.0]", _INK), + ("bass", "bass e(5,16) | tune -7st", _INK), + ("stab", "stab [1.0.0.0.0.0.1.0] | tune 3st", _INK), + ("perc", "perc e(7,16,2)", _INK), +] + +ROOT = Path(__file__).resolve().parent / "remotion" +WAV_OUT = ROOT / "public" / "audx-beat.wav" +JSON_OUT = ROOT / "src" / "beat-data.json" + + +def step_grid(pattern: Pattern) -> list[int]: + """16-cell on/off grid for one bar from a parsed pattern.""" + grid = [0] * STEPS_PER_BAR + for step in pattern.steps: + idx = round(step.beat * 4) % STEPS_PER_BAR + grid[idx] = 1 + return grid + + +def hit_frames(grid: list[int]) -> list[int]: + """Absolute video-frame indices where this track fires, across all bars.""" + spb = 60.0 / BPM # seconds per beat + frames: list[int] = [] + for bar in range(BARS): + for step, on in enumerate(grid): + if not on: + continue + t = (bar * 4 + step / 4.0) * spb + frames.append(round(t * FPS)) + return frames + + +def main() -> None: + spb = 60.0 / BPM + total_seconds = BARS * 4 * spb + total_frames = int(np.ceil(total_seconds * FPS)) + + arrangement = Arrangement(bpm=BPM) + tracks_meta = [] + for name, dsl, color in TRACKS: + pat = Pattern(name=name, dsl=dsl) + pat.parse_dsl() + arrangement.add(pat, start_bar=0, bars=BARS) + grid = step_grid(pat) + tracks_meta.append( + {"name": name, "color": color, "steps": grid, "hits": hit_frames(grid)} + ) + + WAV_OUT.parent.mkdir(parents=True, exist_ok=True) + library = SampleLibrary(Path("/nonexistent-empty")) # synth-only + render_arrangement(arrangement, library, WAV_OUT, sample_rate=SR) + + # Per-frame master envelope (RMS) from the rendered audio, normalised 0..1. + audio, _ = sf.read(str(WAV_OUT), always_2d=True) + mono = audio.mean(axis=1) + samples_per_frame = SR / FPS + env = np.zeros(total_frames, dtype=np.float64) + for f in range(total_frames): + a = int(f * samples_per_frame) + b = int((f + 1) * samples_per_frame) + chunk = mono[a:b] + if chunk.size: + env[f] = float(np.sqrt(np.mean(chunk**2))) + if env.max() > 0: + env = env / env.max() + + # Downsampled waveform peaks for a static scope drawing. + n_peaks = 1400 + peaks = [] + win = max(1, len(mono) // n_peaks) + for i in range(n_peaks): + seg = mono[i * win : (i + 1) * win] + peaks.append(round(float(np.max(np.abs(seg))) if seg.size else 0.0, 4)) + + data = { + "bpm": BPM, + "fps": FPS, + "bars": BARS, + "stepsPerBar": STEPS_PER_BAR, + "framesPerBar": (4 * spb) * FPS, + "framesPerStep": (spb / 4) * FPS, + "audioDurationFrames": total_frames, + "tracks": tracks_meta, + "envelope": [round(float(x), 4) for x in env], + "waveform": peaks, + } + JSON_OUT.write_text(json.dumps(data)) + print(f"wrote {WAV_OUT} ({total_seconds:.1f}s) and {JSON_OUT}") + print(f" {len(tracks_meta)} tracks, {total_frames} frames @ {FPS}fps") + + +if __name__ == "__main__": + main() diff --git a/marketing/promo/audx-promo-poster.png b/marketing/promo/audx-promo-poster.png new file mode 100644 index 0000000..3b45f24 Binary files /dev/null and b/marketing/promo/audx-promo-poster.png differ diff --git a/marketing/promo/audx-promo.mp4 b/marketing/promo/audx-promo.mp4 new file mode 100644 index 0000000..fe84066 Binary files /dev/null and b/marketing/promo/audx-promo.mp4 differ diff --git a/marketing/remotion/README.md b/marketing/remotion/README.md new file mode 100644 index 0000000..c73b9c3 --- /dev/null +++ b/marketing/remotion/README.md @@ -0,0 +1,57 @@ +# audx promo video (Remotion) + +A 20-second promo for audx, built with [Remotion](https://www.remotion.dev) +(React → MP4/GIF). It's **audio-reactive**: the visuals are driven by a real audx +beat rendered from the built-in synth kit. A live step-sequencer grid fires on the +beat, per-track VU meters bounce, and a waveform scope sweeps in time — with the +actual beat playing as the soundtrack. Electric-neon palette on pure black. + +## Generate the beat, then render + +The composition reads `src/beat-data.json` and plays `public/audx-beat.wav`, both +produced from audx itself. Generate them first (from the repo root): + +```bash +uv run python marketing/build-beat.py # writes public/audx-beat.wav + src/beat-data.json +``` + +Then, in this directory: + +```bash +npm install + +npm run studio # live preview in the browser +npm run render # → out/audx-promo.mp4 (1920×1080, 30fps, with audio) +npm run render-gif # → out/audx-promo.gif (no audio) +npm run still # → out/poster.png (a single poster frame) +``` + +`npm run render` downloads a headless browser and an ffmpeg binary on first use, +so it needs network access and a few hundred MB of disk. + +## How it's wired + +`marketing/build-beat.py` renders the same patterns it visualises, so the grid and +the sound are guaranteed to agree. It emits, at the video's fps: + +- `tracks[]` — name, neon colour, 16-step on/off grid, and absolute hit frames +- `envelope[]` — per-frame RMS of the master mix (drives the background glow) +- `waveform[]` — downsampled peaks for the scope + +``` +src/ + index.ts # registerRoot + Root.tsx # — 600 frames @ 30fps, 1920×1080 + AudxPromo.tsx # Background, Hero, Sequencer (grid + VU + scope), CTA — all crossfaded + theme.ts # electric-neon palette + font + beat-data.json # generated by build-beat.py (committed) +public/ + audx-beat.wav # generated by build-beat.py (gitignored — regenerate it) +``` + +Tweak copy/timing in `AudxPromo.tsx`, colours in `theme.ts`, or the beat itself in +`marketing/build-beat.py` (patterns, BPM, bars) and re-render. + +The lightweight terminal-typing GIF in the top-level README +(`docs/assets/audx-demo.gif`) is separate, generated offline by +`scripts/make-demo-gif.py` (Pillow only — no browser/ffmpeg). diff --git a/marketing/remotion/package-lock.json b/marketing/remotion/package-lock.json new file mode 100644 index 0000000..8855cf0 --- /dev/null +++ b/marketing/remotion/package-lock.json @@ -0,0 +1,2944 @@ +{ + "name": "audx-promo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "audx-promo", + "version": "1.0.0", + "dependencies": { + "@remotion/cli": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "remotion": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "typescript": "^5.5.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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.47.0.tgz", + "integrity": "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.47.0.tgz", + "integrity": "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.47.0.tgz", + "integrity": "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.481.tgz", + "integrity": "sha512-fBe59X+V8Kvb6zN9hoBaTYHay3L1I5a0Q3xguwLnXM4W3pMdKcZCMiQWblSOoq1V9IZ7TK5ETrHVB8uQtP/hNA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.481", + "@remotion/studio": "4.0.481", + "@remotion/studio-shared": "4.0.481", + "@remotion/timeline-utils": "4.0.481", + "@rspack/core": "1.7.11", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.1", + "react-refresh": "0.18.0", + "remotion": "4.0.481", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/canvas-capture": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/canvas-capture/-/canvas-capture-4.0.481.tgz", + "integrity": "sha512-bEaW8Aaj2PdPZt3ZCwdqBqoGPeSLqfkXro0uls2XS/wIIpMiu+2iCFFWdOI4xQ+GhXO8E3X0aW8vc+ZdscefqQ==", + "license": "Remotion License", + "dependencies": { + "mediabunny": "1.47.0", + "remotion": "4.0.481" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/cli": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.481.tgz", + "integrity": "sha512-279jl70VqX3REQM2TJqNbsE7OEecuKh/TdDWmfZiVbqStVwHRIDfrTgiA4x9E8rQZfSKSGOri+ONEOcyZHJ0vQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/bundler": "4.0.481", + "@remotion/media-utils": "4.0.481", + "@remotion/player": "4.0.481", + "@remotion/renderer": "4.0.481", + "@remotion/studio": "4.0.481", + "@remotion/studio-server": "4.0.481", + "@remotion/studio-shared": "4.0.481", + "dotenv": "17.3.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.481" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.481.tgz", + "integrity": "sha512-R4jI8wmiPOGbn0dFtZGJHJRlW1rVc4bZ9qtkjXrtGIWx//m8joNofVyUpR2Z30z48AhESfS6X7lE5XyEROayYg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.481.tgz", + "integrity": "sha512-AID+uY7zKGvhUCL9CvNDmAbYmX3vylylez+yKPnAunAncQK5TOzHE6PYj9po0UZCv9LuYc5jPJKWke6wLUK3XQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.481.tgz", + "integrity": "sha512-lOMzBoQTW9aK4OFN4Q2z6yRYwAN2QpvboiJ/AkR1DVJoemfyVwgFZNNkGO+BkcFQnR9DTv3UVrDj7iVSrpp4vw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.481.tgz", + "integrity": "sha512-NkjFbp6+BQhTf6JwsNEmM1Q6wK2mluCqv5MfKrrFr1oofQ5EpMfL5OYLtpwIJFAp5+mtg3yTi1+gSE22JrtTKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.481.tgz", + "integrity": "sha512-P8FqjbXXCATAB977/9CrOiwNugtsGSN5ZOgeTOy6uavydXV8+Psf0uNPR4Iri/dEVwdRHui98izVMs57cY5frg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.481.tgz", + "integrity": "sha512-BdQLH2rJdIMNfZafcjmyUrhGcCJQ4OaSpEIySpwPEBdxvewrebh29PybvN9K5eEYSxoMhz5hyUNu4ihNy1YPtA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.481.tgz", + "integrity": "sha512-Pe81TQ+W85jQDI6/hUK9Ya3N8qO59GfUS0+ALiSOSPdRmvNul0CU0sQrkN/pRWXfmPks9/Tvo0hNuarfNYMAyQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.481.tgz", + "integrity": "sha512-vUWA26GZKhp/Tl0Sj2E/M5SRZIQttMMT3kqXB7Se9wvGMAfSqm/86HLkY3dRNaagF2jSXJ7w+LY7EkXOTXVMBw==", + "license": "MIT" + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.481.tgz", + "integrity": "sha512-VM0slWAYdSKGanBWn92B7eV7zheLN2mYaoccXDJemC0Ev4EIx40lcvFJAiMJ1yR0WoRn6Z6zLCbgPQrWmzVezg==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.481.tgz", + "integrity": "sha512-uHO8WY7vIMfTK9qc/Q8cbkUCCDQSLWrBb0OA1QBLuV0yhmutcevEnrkI4YeGZ2EqXq6RyTbXFIbLG6kU+hzCTw==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.47.0", + "remotion": "4.0.481" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.481.tgz", + "integrity": "sha512-+oRSxxzA6B7J2RvWBAaHAGL2IchybA3TnK6e0/IAJfTUa9m0wN95GLels9MA2/EYuEQL+9Ydtx/lZSESJcyL5A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.481" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.481.tgz", + "integrity": "sha512-vLQayl0Tktt7yaL3X+0k0tde/8agPegdN7cc9qhARdMBwoU6mBIKK/KykhidHIDbfttcBH0lKRw3tKTjR+AeuQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.481", + "@remotion/streaming": "4.0.481", + "execa": "5.1.1", + "remotion": "4.0.481", + "source-map": "0.8.0-beta.0", + "ws": "8.21.0" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.481", + "@remotion/compositor-darwin-x64": "4.0.481", + "@remotion/compositor-linux-arm64-gnu": "4.0.481", + "@remotion/compositor-linux-arm64-musl": "4.0.481", + "@remotion/compositor-linux-x64-gnu": "4.0.481", + "@remotion/compositor-linux-x64-musl": "4.0.481", + "@remotion/compositor-win32-x64-msvc": "4.0.481" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.481.tgz", + "integrity": "sha512-EUb/98wR3cE10tnsjUv6Cj3sylPn4yIC8iDu4aFry5dIjzXoqGsyuiE9OS7E6qLwUVLRyS1eK990Snf+mggxiw==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.481.tgz", + "integrity": "sha512-wmwsNzQEzKHT7PWg/VcRNfy6iM/I0EFKvsQjbLNqWqZQLCKUxevUUeuZ8dfX+TqefhQHxb24mkJkFppO1Q35kA==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/canvas-capture": "4.0.481", + "@remotion/media-utils": "4.0.481", + "@remotion/player": "4.0.481", + "@remotion/renderer": "4.0.481", + "@remotion/studio-shared": "4.0.481", + "@remotion/timeline-utils": "4.0.481", + "@remotion/web-renderer": "4.0.481", + "@remotion/zod-types": "4.0.481", + "mediabunny": "1.47.0", + "memfs": "3.4.3", + "open": "8.4.2", + "remotion": "4.0.481", + "semver": "7.5.3", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.481.tgz", + "integrity": "sha512-5cbTVBmoqk3wQBgXrc2X9niKR3RuxBKbdu3zRud3Iblrpw4fSTD1kb15RLegO9ubp4CKIilR9JpW1M/PheauhQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.481", + "@remotion/renderer": "4.0.481", + "@remotion/studio-shared": "4.0.481", + "memfs": "3.4.3", + "open": "8.4.2", + "prettier": "3.8.1", + "recast": "0.23.11", + "remotion": "4.0.481", + "semver": "7.5.3" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.481.tgz", + "integrity": "sha512-0ywlkD0esuLiOLF4m1mGrLQONs3u8929zG5+GMSVAPq1iLjBfjowotPMABBnt2cpFzNYanv0L9fVx+tUJ0tBzA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.481" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.481.tgz", + "integrity": "sha512-xyZ4YRsOPW+NLLA4oqt9Ager/eoHrckH0Ufvh4MD7hSlNtm0tA9uiVNOAi2Ztq8RUkXMUwdwec6RNsydPVplmg==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.47.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.481.tgz", + "integrity": "sha512-H82Bnpfp6occIJcaLv7j6SVolloxw4j/cHykD1cIVtd/y+8tmfsh/nNgfLWYqjudj3VL5sY3CGy0FNPG6Wg2tQ==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.47.0", + "@mediabunny/flac-encoder": "1.47.0", + "@mediabunny/mp3-encoder": "1.47.0", + "@remotion/licensing": "4.0.481", + "mediabunny": "1.47.0", + "remotion": "4.0.481" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.481.tgz", + "integrity": "sha512-NAol+4qnhetJY3x2CfOadEiSTMrjxHv71OA7IWqf97OADNJ0neb5jwhqcQB2IeLSVMXbqCGyidB9iNzCRX7fFw==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.481" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "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/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "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==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", + "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mediabunny": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.47.0.tgz", + "integrity": "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "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/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.481", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.481.tgz", + "integrity": "sha512-OurH3eP6zoS9fwAOTpOy73kz1k7v6cshLoig7jYEI/mfn5fFdXU5/fiBOQom5N4QidnVyDF7ix/SATyv6AY2Cg==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "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==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/marketing/remotion/package.json b/marketing/remotion/package.json new file mode 100644 index 0000000..7308f7d --- /dev/null +++ b/marketing/remotion/package.json @@ -0,0 +1,22 @@ +{ + "name": "audx-promo", + "version": "1.0.0", + "private": true, + "description": "Remotion promo video for audx — the terminal-native DAW", + "scripts": { + "studio": "remotion studio", + "render": "remotion render AudxPromo out/audx-promo.mp4", + "render-gif": "remotion render AudxPromo out/audx-promo.gif --codec=gif", + "still": "remotion still AudxPromo out/poster.png --frame=120" + }, + "dependencies": { + "@remotion/cli": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "remotion": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "typescript": "^5.5.0" + } +} diff --git a/marketing/remotion/remotion.config.ts b/marketing/remotion/remotion.config.ts new file mode 100644 index 0000000..37bbed8 --- /dev/null +++ b/marketing/remotion/remotion.config.ts @@ -0,0 +1,5 @@ +import { Config } from "@remotion/cli/config"; + +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.setConcurrency(2); diff --git a/marketing/remotion/src/AudxPromo.tsx b/marketing/remotion/src/AudxPromo.tsx new file mode 100644 index 0000000..c99b91e --- /dev/null +++ b/marketing/remotion/src/AudxPromo.tsx @@ -0,0 +1,357 @@ +import { + AbsoluteFill, + Audio, + Easing, + interpolate, + staticFile, + useCurrentFrame, +} from "remotion"; +import { COLORS, MONO } from "./theme"; +import beat from "./beat-data.json"; + +const FPS = beat.fps; +const FPS_PER_STEP = beat.framesPerStep; +const STEPS = beat.stepsPerBar; + +// calm, consistent easing — restraint over flourish +const ease = Easing.bezier(0.4, 0, 0.2, 1); + +/** Smooth eased 0→1→0 for a scene window. */ +function band( + f: number, + inA: number, + inB: number, + outA: number, + outB: number +): number { + return ( + interpolate(f, [inA, inB], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: ease, + }) * + interpolate(f, [outA, outB], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: ease, + }) + ); +} + +/** Blend two #rrggbb colours by t∈[0,1] (flat per-frame, not a CSS gradient). */ +function mix(a: string, b: string, t: number): string { + const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16)); + const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16)); + const c = pa.map((v, i) => Math.round(v + (pb[i] - v) * t)); + return `rgb(${c[0]},${c[1]},${c[2]})`; +} + +// ── persistent frame: a quiet passe-partout + wordmark ──────────────────────── + +const Frame: React.FC = () => ( + +
+
+ audx +
+
+ terminal-native daw +
+ +); + +// ── hero ────────────────────────────────────────────────────────────────────── + +const Hero: React.FC = () => { + const f = useCurrentFrame(); + const o = band(f, 6, 34, 84, 108); + const y = interpolate(f, [6, 40], [18, 0], { + extrapolateRight: "clamp", + easing: ease, + }); + return ( + +
+
+ code your music. +
+ own your sound. +
+
+ a digital audio workstation you play from the keyboard +
+
+
+ ); +}; + +// ── the sequencer (monochrome LED matrix) ───────────────────────────────────── + +const LABEL_W = 230; +const CELL = 62; +const GAP = 12; + +const Sequencer: React.FC = () => { + const f = useCurrentFrame(); + const o = band(f, 84, 112, 500, 524); + + const stepPos = f / FPS_PER_STEP; + const colF = ((stepPos % STEPS) + STEPS) % STEPS; + const gridW = STEPS * CELL + (STEPS - 1) * GAP; + const y = interpolate(f, [84, 118], [22, 0], { + extrapolateRight: "clamp", + easing: ease, + }); + + return ( + +
+
+ sequencer + 124 bpm · synth kit · no samples +
+ +
+ {/* thin precise playhead rule */} +
+ {beat.tracks.map((track) => ( +
+
+ {track.name} +
+
+ {track.steps.map((on, col) => { + let d = colF - col; + if (d < 0) d += STEPS; + const fresh = Math.exp(-d / 1.3); // 1 just under playhead → 0 + const beatCol = col % 4 === 0; + // inactive: hairline square. active: off-white, amber at the play head. + const fill = on + ? mix(COLORS.ink, COLORS.accent, fresh) + : "transparent"; + const opacity = on ? 0.3 + fresh * 0.7 : 1; + return ( +
+ ); + })} +
+
+ ))} +
+ + +
+ + ); +}; + +// ── flat waveform scope ─────────────────────────────────────────────────────── + +const Scope: React.FC<{ width: number }> = ({ width }) => { + const f = useCurrentFrame(); + const H = 132; + const mid = H / 2; + const n = beat.waveform.length; + const playX = (Math.min(f, beat.audioDurationFrames) / beat.audioDurationFrames) * width; + + const line = (sign: number) => + beat.waveform + .map((p, i) => { + const x = (i / (n - 1)) * width; + const y = mid + sign * p * (mid - 6); + return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + + {[1, -1].map((s) => ( + + ))} + + {[1, -1].map((s) => ( + + ))} + + + + ); +}; + +// ── CTA ─────────────────────────────────────────────────────────────────────── + +const CTA: React.FC = () => { + const f = useCurrentFrame(); + const o = band(f, 508, 536, 999, 1000); + const y = interpolate(f, [508, 544], [18, 0], { + extrapolateRight: "clamp", + easing: ease, + }); + const caret = f % 30 < 15 ? 1 : 0; // discrete blink, not a fade + return ( + +
+
+ open a terminal. hit play. +
+
+ $ pip install audx + +
+
+ github.com/totalaudiopromo/audx +
+
+
+ ); +}; + +// ── composition ─────────────────────────────────────────────────────────────── + +export const AudxPromo: React.FC = () => { + return ( + + + ); +}; diff --git a/marketing/remotion/src/Root.tsx b/marketing/remotion/src/Root.tsx new file mode 100644 index 0000000..9fe76bb --- /dev/null +++ b/marketing/remotion/src/Root.tsx @@ -0,0 +1,16 @@ +import { Composition } from "remotion"; +import { AudxPromo } from "./AudxPromo"; + +// 20s promo at 30fps = 600 frames, locked to the 124 BPM beat in public/. +export const RemotionRoot: React.FC = () => { + return ( + + ); +}; diff --git a/marketing/remotion/src/beat-data.json b/marketing/remotion/src/beat-data.json new file mode 100644 index 0000000..654dbeb --- /dev/null +++ b/marketing/remotion/src/beat-data.json @@ -0,0 +1 @@ +{"bpm": 124.0, "fps": 30, "bars": 12, "stepsPerBar": 16, "framesPerBar": 58.064516129032256, "framesPerStep": 3.629032258064516, "audioDurationFrames": 697, "tracks": [{"name": "kick", "color": "#ececea", "steps": [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], "hits": [0, 15, 29, 44, 58, 73, 87, 102, 116, 131, 145, 160, 174, 189, 203, 218, 232, 247, 261, 276, 290, 305, 319, 334, 348, 363, 377, 392, 406, 421, 435, 450, 465, 479, 494, 508, 523, 537, 552, 566, 581, 595, 610, 624, 639, 653, 668, 682]}, {"name": "clap", "color": "#ececea", "steps": [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], "hits": [15, 44, 73, 102, 131, 160, 189, 218, 247, 276, 305, 334, 363, 392, 421, 450, 479, 508, 537, 566, 595, 624, 653, 682]}, {"name": "hats", "color": "#ececea", "steps": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], "hits": [0, 4, 7, 11, 15, 18, 22, 25, 29, 33, 36, 40, 44, 47, 51, 54, 58, 62, 65, 69, 73, 76, 80, 83, 87, 91, 94, 98, 102, 105, 109, 112, 116, 120, 123, 127, 131, 134, 138, 142, 145, 149, 152, 156, 160, 163, 167, 171, 174, 178, 181, 185, 189, 192, 196, 200, 203, 207, 210, 214, 218, 221, 225, 229, 232, 236, 240, 243, 247, 250, 254, 258, 261, 265, 269, 272, 276, 279, 283, 287, 290, 294, 298, 301, 305, 308, 312, 316, 319, 323, 327, 330, 334, 338, 341, 345, 348, 352, 356, 359, 363, 367, 370, 374, 377, 381, 385, 388, 392, 396, 399, 403, 406, 410, 414, 417, 421, 425, 428, 432, 435, 439, 443, 446, 450, 454, 457, 461, 465, 468, 472, 475, 479, 483, 486, 490, 494, 497, 501, 504, 508, 512, 515, 519, 523, 526, 530, 533, 537, 541, 544, 548, 552, 555, 559, 562, 566, 570, 573, 577, 581, 584, 588, 592, 595, 599, 602, 606, 610, 613, 617, 621, 624, 628, 631, 635, 639, 642, 646, 650, 653, 657, 660, 664, 668, 671, 675, 679, 682, 686, 690, 693]}, {"name": "oh", "color": "#ececea", "steps": [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], "hits": [15, 47, 73, 105, 131, 163, 189, 221, 247, 279, 305, 338, 363, 396, 421, 454, 479, 512, 537, 570, 595, 628, 653, 686]}, {"name": "bass", "color": "#ececea", "steps": [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], "hits": [11, 22, 33, 44, 54, 69, 80, 91, 102, 112, 127, 138, 149, 160, 171, 185, 196, 207, 218, 229, 243, 254, 265, 276, 287, 301, 312, 323, 334, 345, 359, 370, 381, 392, 403, 417, 428, 439, 450, 461, 475, 486, 497, 508, 519, 533, 544, 555, 566, 577, 592, 602, 613, 624, 635, 650, 660, 671, 682, 693]}, {"name": "stab", "color": "#ececea", "steps": [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], "hits": [0, 47, 58, 105, 116, 163, 174, 221, 232, 279, 290, 338, 348, 396, 406, 454, 465, 512, 523, 570, 581, 628, 639, 686]}, {"name": "perc", "color": "#ececea", "steps": [0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1], "hits": [4, 15, 22, 29, 40, 47, 54, 62, 73, 80, 87, 98, 105, 112, 120, 131, 138, 145, 156, 163, 171, 178, 189, 196, 203, 214, 221, 229, 236, 247, 254, 261, 272, 279, 287, 294, 305, 312, 319, 330, 338, 345, 352, 363, 370, 377, 388, 396, 403, 410, 421, 428, 435, 446, 454, 461, 468, 479, 486, 494, 504, 512, 519, 526, 537, 544, 552, 562, 570, 577, 584, 595, 602, 610, 621, 628, 635, 642, 653, 660, 668, 679, 686, 693]}], "envelope": [0.6157, 0.4834, 0.3455, 0.2894, 0.3158, 0.1815, 0.1363, 0.2481, 0.0999, 0.0757, 0.109, 0.6299, 0.6501, 0.5819, 0.6391, 0.6438, 0.7224, 0.5217, 0.4275, 0.4338, 0.4051, 0.5448, 0.7856, 0.7208, 0.7309, 0.5547, 0.5093, 0.5308, 0.4656, 0.7057, 0.4544, 0.5299, 0.6792, 0.7532, 0.5883, 0.6863, 0.5998, 0.5268, 0.5297, 0.4862, 0.4104, 0.3264, 0.3519, 0.819, 0.842, 0.7755, 0.8027, 0.8719, 0.6835, 0.5317, 0.5417, 0.3927, 0.3454, 0.3661, 0.7049, 0.7558, 0.6406, 0.6546, 0.8607, 0.6163, 0.6369, 0.4297, 0.476, 0.4061, 0.3734, 0.4216, 0.2401, 0.1519, 0.1659, 0.5444, 0.6167, 0.5705, 0.6095, 0.6247, 0.7205, 0.5272, 0.3845, 0.4407, 0.4066, 0.4229, 0.8082, 0.7727, 0.7226, 0.5574, 0.501, 0.5443, 0.4557, 0.6809, 0.5085, 0.5257, 0.6324, 0.7711, 0.6059, 0.6981, 0.5651, 0.5302, 0.5608, 0.4816, 0.4155, 0.3187, 0.3594, 0.7633, 0.8822, 0.764, 0.7986, 0.8745, 0.6907, 0.5542, 0.5392, 0.3945, 0.3421, 0.3799, 0.695, 0.7194, 0.6529, 0.6905, 0.8602, 0.618, 0.6007, 0.4766, 0.4359, 0.4195, 0.4103, 0.4216, 0.236, 0.1566, 0.1637, 0.5278, 0.6257, 0.5636, 0.6145, 0.6194, 0.7244, 0.5341, 0.3807, 0.4476, 0.4061, 0.3345, 0.8007, 0.8187, 0.7058, 0.5621, 0.5126, 0.5523, 0.4395, 0.6378, 0.5774, 0.5244, 0.55, 0.8041, 0.643, 0.6895, 0.5566, 0.5256, 0.5823, 0.4716, 0.4203, 0.3262, 0.3646, 0.6226, 0.9296, 0.8024, 0.8026, 0.8461, 0.7191, 0.5968, 0.523, 0.412, 0.3412, 0.3878, 0.6689, 0.7241, 0.6426, 0.7152, 0.8512, 0.6054, 0.6, 0.5153, 0.4127, 0.4195, 0.4312, 0.4254, 0.2306, 0.1631, 0.1645, 0.5218, 0.6254, 0.5531, 0.6191, 0.615, 0.7274, 0.5365, 0.3918, 0.4472, 0.4043, 0.3252, 0.7681, 0.8453, 0.6907, 0.555, 0.5546, 0.5571, 0.4145, 0.6284, 0.6002, 0.5302, 0.4482, 0.8257, 0.6924, 0.6637, 0.5835, 0.5134, 0.5891, 0.4624, 0.4205, 0.3507, 0.367, 0.509, 0.9119, 0.8787, 0.8061, 0.8186, 0.7324, 0.644, 0.5051, 0.4387, 0.3388, 0.3889, 0.6148, 0.7637, 0.6307, 0.7254, 0.8385, 0.5609, 0.6652, 0.5054, 0.4202, 0.4061, 0.453, 0.4265, 0.2216, 0.1748, 0.1683, 0.5217, 0.6267, 0.5324, 0.585, 0.6504, 0.7331, 0.5294, 0.4051, 0.4348, 0.4069, 0.3304, 0.7535, 0.8628, 0.6617, 0.5545, 0.5958, 0.5547, 0.3959, 0.6414, 0.5956, 0.5341, 0.3922, 0.8082, 0.7434, 0.6328, 0.5934, 0.5396, 0.5911, 0.445, 0.4239, 0.3752, 0.3647, 0.4756, 0.8674, 0.9378, 0.7951, 0.7989, 0.7405, 0.6608, 0.5141, 0.4334, 0.3552, 0.3921, 0.5416, 0.7785, 0.6693, 0.7347, 0.769, 0.5912, 0.722, 0.4845, 0.4435, 0.3992, 0.4568, 0.4272, 0.2285, 0.1697, 0.1753, 0.514, 0.6311, 0.5085, 0.5225, 0.7059, 0.7426, 0.5309, 0.3986, 0.4342, 0.393, 0.3395, 0.7542, 0.8679, 0.6265, 0.5605, 0.626, 0.5474, 0.4029, 0.6445, 0.5928, 0.5206, 0.4134, 0.7581, 0.7846, 0.6093, 0.5887, 0.5851, 0.5917, 0.4202, 0.4269, 0.3987, 0.3589, 0.428, 0.8691, 0.959, 0.74, 0.843, 0.7087, 0.694, 0.5027, 0.4427, 0.368, 0.3941, 0.4485, 0.7931, 0.7166, 0.7345, 0.6835, 0.668, 0.7336, 0.4703, 0.466, 0.4012, 0.4533, 0.4248, 0.2416, 0.1678, 0.1813, 0.4844, 0.6462, 0.4929, 0.5059, 0.7213, 0.7345, 0.5542, 0.3883, 0.4472, 0.3747, 0.3444, 0.7613, 0.8652, 0.6034, 0.5602, 0.6516, 0.5334, 0.4106, 0.6481, 0.5975, 0.4828, 0.4531, 0.7248, 0.8164, 0.5775, 0.5842, 0.622, 0.591, 0.4055, 0.4228, 0.4181, 0.3496, 0.3426, 0.9022, 0.9471, 0.6739, 0.8973, 0.7273, 0.6967, 0.5026, 0.4368, 0.3826, 0.3901, 0.3493, 0.799, 0.764, 0.7276, 0.6857, 0.6717, 0.716, 0.4877, 0.4412, 0.4451, 0.4509, 0.4124, 0.2611, 0.1748, 0.1817, 0.4408, 0.6646, 0.4898, 0.4736, 0.7414, 0.711, 0.5957, 0.3781, 0.456, 0.3713, 0.3431, 0.7614, 0.86, 0.6088, 0.5471, 0.6633, 0.5227, 0.4131, 0.6372, 0.6144, 0.45, 0.4848, 0.7248, 0.8229, 0.5538, 0.5819, 0.6479, 0.5855, 0.4107, 0.4107, 0.4295, 0.342, 0.2727, 0.9225, 0.9539, 0.6483, 0.8232, 0.8285, 0.6932, 0.4925, 0.445, 0.4003, 0.3807, 0.2705, 0.7949, 0.8027, 0.7103, 0.6881, 0.6866, 0.7192, 0.4755, 0.4267, 0.4717, 0.4501, 0.3859, 0.2927, 0.1865, 0.1791, 0.4264, 0.658, 0.4696, 0.4958, 0.7401, 0.6841, 0.6303, 0.3872, 0.4485, 0.3852, 0.3468, 0.754, 0.8496, 0.6134, 0.5573, 0.6717, 0.5015, 0.4185, 0.6408, 0.6105, 0.4463, 0.4825, 0.7409, 0.8125, 0.5589, 0.5697, 0.6684, 0.5715, 0.4141, 0.4169, 0.441, 0.3266, 0.2765, 0.9059, 0.9733, 0.5995, 0.8225, 0.8621, 0.6956, 0.4805, 0.4551, 0.4064, 0.3666, 0.2892, 0.7592, 0.8268, 0.6941, 0.6548, 0.7447, 0.7183, 0.4755, 0.4172, 0.4788, 0.4539, 0.3682, 0.3027, 0.1995, 0.1808, 0.4104, 0.6491, 0.4598, 0.5267, 0.7363, 0.6588, 0.6421, 0.4112, 0.4344, 0.3938, 0.3697, 0.7337, 0.8406, 0.6171, 0.5899, 0.6752, 0.473, 0.4258, 0.649, 0.6131, 0.4535, 0.4535, 0.7504, 0.802, 0.582, 0.5557, 0.6709, 0.5618, 0.4226, 0.4335, 0.4503, 0.3074, 0.2815, 0.8862, 0.9937, 0.5742, 0.821, 0.876, 0.7054, 0.4839, 0.4414, 0.4216, 0.3484, 0.3137, 0.7371, 0.8402, 0.6735, 0.6753, 0.754, 0.7211, 0.4726, 0.4134, 0.4871, 0.4483, 0.3544, 0.3148, 0.2161, 0.1758, 0.4025, 0.6316, 0.4641, 0.5365, 0.7408, 0.6398, 0.6435, 0.4431, 0.4166, 0.3934, 0.3954, 0.7258, 0.8142, 0.6294, 0.6261, 0.6708, 0.4589, 0.4256, 0.6291, 0.6495, 0.4235, 0.4646, 0.7348, 0.8066, 0.5747, 0.5751, 0.6795, 0.5355, 0.4383, 0.4475, 0.4583, 0.3011, 0.2791, 0.8854, 0.9971, 0.5754, 0.7929, 0.8844, 0.7164, 0.4937, 0.4419, 0.4301, 0.3336, 0.3177, 0.7342, 0.8483, 0.6367, 0.6944, 0.7663, 0.7217, 0.4729, 0.4105, 0.4888, 0.4401, 0.3429, 0.3384, 0.2252, 0.1677, 0.3832, 0.6158, 0.4831, 0.5377, 0.7482, 0.5928, 0.6753, 0.4635, 0.4076, 0.3871, 0.4117, 0.727, 0.769, 0.6485, 0.6622, 0.6601, 0.4701, 0.4151, 0.6262, 0.664, 0.3587, 0.5142, 0.7251, 0.7953, 0.5695, 0.5974, 0.6894, 0.4994, 0.4561, 0.4549, 0.4664, 0.3095, 0.2729, 0.8804, 1.0, 0.575, 0.7798, 0.8805, 0.7263, 0.4949, 0.4533, 0.4321, 0.3196, 0.3216, 0.7388, 0.8472, 0.6025, 0.5825], "waveform": [0.6055, 0.4724, 0.4111, 0.3434, 0.2699, 0.2372, 0.2789, 0.2092, 0.4514, 0.1965, 0.1534, 0.1005, 0.1215, 0.0865, 0.3301, 0.2227, 0.0973, 0.0627, 0.0545, 0.0418, 0.0375, 0.0871, 0.5062, 0.3956, 0.3188, 0.2788, 0.2365, 0.2378, 0.2118, 0.7534, 0.615, 0.5089, 0.6768, 0.6428, 0.5294, 0.4379, 0.3671, 0.4119, 0.36, 0.372, 0.3024, 0.2857, 0.1982, 0.3958, 0.6627, 0.4374, 0.4583, 0.3886, 0.3469, 0.3395, 0.2958, 0.5863, 0.4995, 0.2257, 0.2477, 0.2304, 0.1801, 0.2066, 0.6475, 0.4319, 0.4214, 0.2231, 0.3092, 0.3082, 0.1872, 0.4291, 0.5277, 0.4396, 0.3997, 0.2653, 0.2651, 0.3188, 0.3462, 0.4124, 0.3475, 0.2463, 0.2756, 0.2661, 0.2058, 0.2066, 0.1956, 0.3351, 0.2654, 0.1725, 0.1565, 0.1519, 0.1117, 0.7249, 0.8306, 0.5855, 0.7226, 0.4418, 0.391, 0.8161, 0.6757, 0.9955, 0.6916, 0.5337, 0.5381, 0.4396, 0.3921, 0.3713, 0.4537, 0.3284, 0.3245, 0.2299, 0.2599, 0.2602, 0.1532, 0.4764, 0.6409, 0.448, 0.4086, 0.2985, 0.329, 0.3043, 0.5006, 0.6909, 0.7371, 0.3987, 0.4975, 0.4102, 0.3632, 0.2176, 0.4044, 0.3389, 0.3685, 0.2192, 0.2471, 0.2724, 0.1862, 0.4655, 0.2531, 0.1703, 0.1297, 0.1014, 0.083, 0.0783, 0.248, 0.4395, 0.4421, 0.3524, 0.2913, 0.2826, 0.2838, 0.5631, 0.738, 0.5589, 0.5385, 0.6463, 0.6221, 0.567, 0.423, 0.3333, 0.3833, 0.372, 0.3095, 0.3024, 0.2761, 0.1982, 0.6627, 0.6547, 0.4583, 0.4412, 0.3112, 0.3469, 0.3395, 0.255, 0.5863, 0.4277, 0.2257, 0.2477, 0.2296, 0.205, 0.2066, 0.6475, 0.4214, 0.3746, 0.2231, 0.3092, 0.2976, 0.2287, 0.464, 0.5287, 0.4395, 0.3559, 0.2574, 0.3188, 0.3, 0.3922, 0.4124, 0.3475, 0.2463, 0.2756, 0.2538, 0.2066, 0.2065, 0.3351, 0.2963, 0.2268, 0.1398, 0.1565, 0.1519, 0.1242, 0.8306, 0.8245, 0.7226, 0.7006, 0.4418, 0.4182, 0.8161, 0.6757, 0.9955, 0.6899, 0.5361, 0.5381, 0.3595, 0.3921, 0.4138, 0.4537, 0.3284, 0.2879, 0.2096, 0.2599, 0.2602, 0.2033, 0.6306, 0.5724, 0.4479, 0.4051, 0.2665, 0.329, 0.3043, 0.6909, 0.7371, 0.484, 0.4732, 0.4975, 0.3186, 0.3632, 0.2459, 0.4044, 0.3685, 0.3247, 0.2192, 0.2724, 0.2692, 0.2113, 0.4655, 0.1761, 0.1488, 0.1297, 0.1014, 0.083, 0.0757, 0.2604, 0.4421, 0.3524, 0.3486, 0.2913, 0.2838, 0.2396, 0.6006, 0.7378, 0.559, 0.4668, 0.726, 0.5223, 0.4866, 0.4111, 0.4122, 0.3561, 0.3613, 0.29, 0.3035, 0.2875, 0.224, 0.6583, 0.5314, 0.4583, 0.4351, 0.2613, 0.3466, 0.3395, 0.255, 0.5863, 0.3896, 0.2459, 0.2477, 0.211, 0.2066, 0.5461, 0.6475, 0.4214, 0.3746, 0.1924, 0.3092, 0.2976, 0.2419, 0.5287, 0.4744, 0.4395, 0.3559, 0.2398, 0.3188, 0.3, 0.3915, 0.4121, 0.3421, 0.2756, 0.2755, 0.1827, 0.2066, 0.2005, 0.3351, 0.2963, 0.2197, 0.1427, 0.1565, 0.1503, 0.1359, 0.8306, 0.6062, 0.7226, 0.7006, 0.4418, 0.5562, 0.8161, 0.6327, 0.9955, 0.6451, 0.5381, 0.5017, 0.3595, 0.3921, 0.4537, 0.3446, 0.3245, 0.2879, 0.2096, 0.2602, 0.2581, 0.2059, 0.6306, 0.3985, 0.4479, 0.4051, 0.3187, 0.329, 0.2986, 0.6909, 0.7371, 0.3987, 0.4975, 0.4774, 0.3186, 0.3632, 0.2736, 0.4044, 0.3685, 0.3247, 0.232, 0.2724, 0.1636, 0.4655, 0.388, 0.1703, 0.1488, 0.1051, 0.1014, 0.0783, 0.0753, 0.4395, 0.4421, 0.3524, 0.2972, 0.2913, 0.2838, 0.2067, 0.7378, 0.5951, 0.5231, 0.6248, 0.726, 0.5223, 0.4866, 0.4111, 0.4378, 0.4111, 0.356, 0.2871, 0.3035, 0.2701, 0.2426, 0.6583, 0.3625, 0.4583, 0.4351, 0.3351, 0.3466, 0.3395, 0.2986, 0.5863, 0.3415, 0.2477, 0.2408, 0.1697, 0.2066, 0.6475, 0.4866, 0.4214, 0.2751, 0.1737, 0.3092, 0.2324, 0.2419, 0.5287, 0.4394, 0.4395, 0.3413, 0.2472, 0.3188, 0.3, 0.406, 0.4121, 0.283, 0.2756, 0.2661, 0.1617, 0.2066, 0.2005, 0.3351, 0.2912, 0.2134, 0.1562, 0.1565, 0.1335, 0.6686, 0.8306, 0.6062, 0.7226, 0.6289, 0.4418, 0.6474, 0.8197, 1.0, 0.7681, 0.5223, 0.556, 0.5043, 0.3634, 0.3912, 0.494, 0.3501, 0.3019, 0.2876, 0.2218, 0.2602, 0.1975, 0.3159, 0.6416, 0.42, 0.4478, 0.3757, 0.3287, 0.3134, 0.2583, 0.6904, 0.7372, 0.3972, 0.4974, 0.4756, 0.3627, 0.2654, 0.2742, 0.4037, 0.3673, 0.268, 0.2471, 0.2724, 0.133, 0.4657, 0.3537, 0.1695, 0.1458, 0.0693, 0.1019, 0.0785, 0.0685, 0.4388, 0.4428, 0.3523, 0.2545, 0.2914, 0.2838, 0.2535, 0.7379, 0.591, 0.5226, 0.726, 0.6221, 0.5203, 0.4868, 0.3231, 0.4386, 0.411, 0.3559, 0.2726, 0.3034, 0.2054, 0.4329, 0.6664, 0.4081, 0.4522, 0.396, 0.3468, 0.3394, 0.3094, 0.5864, 0.5439, 0.236, 0.2477, 0.2304, 0.1697, 0.2066, 0.647, 0.4313, 0.4213, 0.2223, 0.304, 0.3091, 0.1875, 0.393, 0.5279, 0.4397, 0.4258, 0.2727, 0.2543, 0.3184, 0.2964, 0.4122, 0.3951, 0.2464, 0.2755, 0.2661, 0.2061, 0.2066, 0.1985, 0.3351, 0.2722, 0.1744, 0.1565, 0.1519, 0.1117, 0.7249, 0.8306, 0.6062, 0.7226, 0.4418, 0.391, 0.8161, 0.6817, 0.9612, 0.7632, 0.5068, 0.5533, 0.4935, 0.3921, 0.3713, 0.49, 0.3502, 0.2875, 0.2289, 0.2599, 0.2602, 0.1504, 0.4766, 0.6415, 0.4479, 0.4377, 0.3349, 0.3288, 0.3044, 0.5013, 0.6906, 0.7372, 0.398, 0.4974, 0.4102, 0.3629, 0.238, 0.4039, 0.3367, 0.3673, 0.2196, 0.2471, 0.2724, 0.1672, 0.4657, 0.2724, 0.1695, 0.1293, 0.1019, 0.0829, 0.0785, 0.2093, 0.4388, 0.4428, 0.3523, 0.2914, 0.2775, 0.2838, 0.2535, 0.7379, 0.559, 0.5385, 0.6461, 0.6223, 0.567, 0.4232, 0.3333, 0.3833, 0.372, 0.3107, 0.3024, 0.2761, 0.1982, 0.6627, 0.6547, 0.4583, 0.4458, 0.3206, 0.3469, 0.3395, 0.255, 0.5863, 0.4277, 0.2257, 0.2477, 0.2304, 0.2019, 0.2066, 0.647, 0.4213, 0.4065, 0.2223, 0.3091, 0.2975, 0.2031, 0.4647, 0.5279, 0.4397, 0.3555, 0.2614, 0.3102, 0.3184, 0.3925, 0.4122, 0.3472, 0.2464, 0.2755, 0.262, 0.2062, 0.2066, 0.3351, 0.2963, 0.2443, 0.1398, 0.1565, 0.1519, 0.1177, 0.7477, 0.8306, 0.7226, 0.7006, 0.4418, 0.4182, 0.8161, 0.6757, 0.9955, 0.6899, 0.5361, 0.5381, 0.3595, 0.3921, 0.3386, 0.49, 0.3502, 0.2875, 0.2096, 0.2599, 0.2602, 0.1849, 0.4764, 0.6409, 0.448, 0.4086, 0.2665, 0.329, 0.3043, 0.6909, 0.7371, 0.5121, 0.3987, 0.4975, 0.3186, 0.3632, 0.2459, 0.4044, 0.3685, 0.3247, 0.2192, 0.2471, 0.2724, 0.2113, 0.4655, 0.1761, 0.1488, 0.1297, 0.1014, 0.083, 0.0757, 0.2604, 0.4429, 0.3509, 0.3525, 0.2913, 0.2838, 0.2489, 0.6, 0.738, 0.5589, 0.5385, 0.6463, 0.567, 0.4417, 0.423, 0.3833, 0.3658, 0.372, 0.2786, 0.3024, 0.2761, 0.2103, 0.6627, 0.5248, 0.4583, 0.4352, 0.2614, 0.3469, 0.3395, 0.255, 0.5863, 0.3896, 0.2428, 0.2477, 0.2186, 0.2066, 0.4355, 0.6475, 0.4214, 0.3746, 0.2149, 0.3092, 0.2976, 0.2419, 0.464, 0.5277, 0.4393, 0.3549, 0.2263, 0.3188, 0.3, 0.3922, 0.4124, 0.3422, 0.2728, 0.2756, 0.2053, 0.2066, 0.2006, 0.3351, 0.2963, 0.2197, 0.1398, 0.1565, 0.1513, 0.1349, 0.8306, 0.6062, 0.7226, 0.7006, 0.4418, 0.4182, 0.8161, 0.6327, 0.9955, 0.6451, 0.5381, 0.5017, 0.3595, 0.3921, 0.49, 0.332, 0.3502, 0.2875, 0.2096, 0.2599, 0.2602, 0.2059, 0.6409, 0.4589, 0.448, 0.4086, 0.2961, 0.329, 0.3024, 0.6909, 0.7371, 0.3987, 0.4975, 0.4774, 0.3186, 0.3632, 0.2736, 0.4044, 0.3685, 0.3247, 0.228, 0.2724, 0.1657, 0.4655, 0.4008, 0.1703, 0.1488, 0.1212, 0.1014, 0.0783, 0.0757, 0.4102, 0.4421, 0.3524, 0.3047, 0.2913, 0.2838, 0.2148, 0.738, 0.6531, 0.5385, 0.6463, 0.6389, 0.567, 0.4417, 0.423, 0.3833, 0.36, 0.372, 0.2786, 0.3024, 0.2761, 0.2325, 0.6627, 0.3629, 0.4583, 0.4352, 0.3121, 0.3469, 0.3395, 0.2957, 0.5863, 0.3419, 0.2477, 0.2444, 0.1702, 0.2066, 0.6475, 0.4866, 0.4214, 0.3514, 0.1737, 0.3092, 0.2676, 0.2419, 0.5287, 0.4394, 0.4395, 0.3559, 0.2472, 0.3188, 0.3, 0.4012, 0.4124, 0.3123, 0.2756, 0.2661, 0.1617, 0.2066, 0.2005, 0.3351, 0.2943, 0.2133, 0.1549, 0.1565, 0.1396, 0.6686, 0.8306, 0.6062, 0.7226, 0.7006, 0.4418, 0.6284, 0.8161, 0.9742, 0.9955, 0.5337, 0.5381, 0.5017, 0.3595, 0.3921, 0.4537, 0.3446, 0.3245, 0.2879, 0.222, 0.2602, 0.1971, 0.2059, 0.6409, 0.4167, 0.448, 0.3923, 0.329, 0.3134, 0.2735, 0.6909, 0.7371, 0.3987, 0.4975, 0.4774, 0.3632, 0.2654, 0.2736, 0.4044, 0.3685, 0.3156, 0.2471, 0.2724, 0.1325, 0.4655, 0.3543, 0.1703, 0.1456, 0.0795, 0.1014, 0.0783, 0.0713, 0.4395, 0.4421, 0.3524, 0.2627, 0.2913, 0.2838, 0.2535, 0.7378, 0.5951, 0.5231, 0.726, 0.6219, 0.5223, 0.4866, 0.3234, 0.4122, 0.3613, 0.3417, 0.2726, 0.3035, 0.2043, 0.3967, 0.6583, 0.3878, 0.4583, 0.4106, 0.3449, 0.3466, 0.3176, 0.5863, 0.5674, 0.2747, 0.2477, 0.2304, 0.1697, 0.2066, 0.6475, 0.4319, 0.4214, 0.2231, 0.2827, 0.3092, 0.1872, 0.2419, 0.5287, 0.4394, 0.4395, 0.285, 0.2472, 0.3188, 0.3, 0.4121, 0.404, 0.2443, 0.2756, 0.2661, 0.2029, 0.2066, 0.1998, 0.3351, 0.2745, 0.1897, 0.1565, 0.1541, 0.1117, 0.7249, 0.8306, 0.6062, 0.7226, 0.4444, 0.391, 0.8161, 0.8083, 0.9955, 0.6916, 0.5337, 0.5381, 0.4935, 0.3921, 0.3713, 0.4537, 0.3284, 0.3245, 0.2673, 0.2599, 0.2602, 0.1504, 0.4219, 0.6306, 0.4253, 0.4479, 0.342, 0.329, 0.3059, 0.5006, 0.6909, 0.7371, 0.3987, 0.4975, 0.4102, 0.3632, 0.2654, 0.4044, 0.337, 0.3685, 0.2676, 0.2471, 0.2724, 0.1325, 0.4655, 0.3152, 0.1703, 0.1297, 0.1014, 0.083, 0.0783, 0.1705, 0.4395, 0.4421, 0.3524, 0.2913, 0.2693, 0.2838, 0.2535, 0.7378, 0.5951, 0.5231, 0.726, 0.6219, 0.5223, 0.4111, 0.3234, 0.4378, 0.4111, 0.356, 0.3035, 0.2875, 0.2043, 0.6267, 0.6583, 0.4583, 0.45, 0.3561, 0.3466, 0.3395, 0.255, 0.5863, 0.4277, 0.2257, 0.2477, 0.2304, 0.1971, 0.2066, 0.6475, 0.4002, 0.4214, 0.2231, 0.3092, 0.2976, 0.1872, 0.4481, 0.5287, 0.4395, 0.3559, 0.2632, 0.2938, 0.3188, 0.3915, 0.4121, 0.3472, 0.2443, 0.2756, 0.2655, 0.2058, 0.2066, 0.3351, 0.2961, 0.2488, 0.1389, 0.1565, 0.1519, 0.1117, 0.7249, 0.8306, 0.6783, 0.7226, 0.4418, 0.4182, 0.8197, 0.687, 1.0, 0.7681, 0.5422, 0.556, 0.3634, 0.3912, 0.3777, 0.494, 0.3501, 0.2876, 0.2123, 0.2596, 0.2602, 0.1719, 0.4764, 0.6416, 0.4478, 0.4088, 0.2662, 0.3287, 0.3043, 0.6904, 0.7372, 0.6531, 0.3972, 0.4974, 0.3194, 0.3627, 0.2437, 0.4065, 0.3702, 0.3241, 0.2193, 0.2471, 0.2724, 0.2112, 0.4657, 0.2235, 0.1483, 0.1293, 0.1019, 0.0829, 0.0757, 0.2606, 0.4428, 0.3596, 0.3523, 0.2914, 0.2838, 0.2492, 0.6, 0.7379, 0.559, 0.5226, 0.726, 0.5203, 0.5151, 0.4206, 0.3431, 0.4386, 0.411, 0.2879, 0.3034, 0.2876, 0.2126, 0.6664, 0.5327, 0.4522, 0.4283, 0.265, 0.3468, 0.3394, 0.254, 0.5864, 0.4183, 0.2341, 0.2477, 0.2237, 0.2066, 0.2063, 0.647, 0.4213, 0.3766, 0.2223, 0.3091, 0.2975, 0.2421, 0.4647, 0.5279, 0.4397, 0.3555, 0.2316, 0.3184, 0.2996, 0.3925, 0.4122, 0.3472, 0.2656, 0.2755, 0.2245, 0.2066, 0.2034, 0.3351, 0.2963, 0.2197, 0.1398, 0.1565, 0.1519, 0.1329, 0.8305, 0.7727, 0.7226, 0.7007, 0.4415, 0.4181, 0.8197, 0.6873, 1.0, 0.6508, 0.556, 0.5044, 0.3632, 0.3913, 0.494, 0.3668, 0.3502, 0.2876, 0.2085, 0.2596, 0.2602, 0.2058, 0.6416, 0.4669, 0.4478, 0.4088, 0.2855, 0.3287, 0.3039, 0.6904, 0.7372, 0.3972, 0.4974, 0.4756, 0.3194, 0.3627, 0.2742, 0.4037, 0.3673, 0.3247, 0.2211, 0.2724, 0.1869, 0.2112, 0.4657, 0.1695, 0.1483, 0.1293, 0.1019, 0.0829, 0.0757, 0.2606, 0.4428, 0.3523, 0.3062, 0.2914, 0.2838, 0.222, 0.7198, 0.7379, 0.559, 0.56, 0.726, 0.5203, 0.4868, 0.4206, 0.4386, 0.411, 0.3559, 0.2879, 0.3034, 0.2876, 0.2426, 0.6664, 0.4111, 0.4522, 0.4283, 0.304, 0.3468, 0.3394, 0.29, 0.6035, 0.3727, 0.2477, 0.2469, 0.1866, 0.2066, 0.647, 0.4874, 0.4213, 0.3766, 0.1722, 0.3091, 0.2905, 0.2421, 0.5279, 0.4395, 0.4397, 0.3555, 0.2477, 0.3184, 0.2996, 0.3925, 0.4122, 0.3229, 0.2755, 0.2662, 0.1619, 0.2066, 0.2005, 0.3351, 0.2963, 0.215, 0.1527, 0.1565, 0.1445, 0.6686, 0.8306, 0.6062, 0.7226, 0.7006, 0.4418, 0.6284, 0.8161, 0.6327, 0.9612, 0.5163, 0.5533, 0.5017, 0.3595, 0.3921, 0.49, 0.3502, 0.3019, 0.2875, 0.2216, 0.2602, 0.2074, 0.2059, 0.6409, 0.4167, 0.448, 0.3923, 0.329, 0.3134]} \ No newline at end of file diff --git a/marketing/remotion/src/index.ts b/marketing/remotion/src/index.ts new file mode 100644 index 0000000..f31c790 --- /dev/null +++ b/marketing/remotion/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/marketing/remotion/src/theme.ts b/marketing/remotion/src/theme.ts new file mode 100644 index 0000000..72d882f --- /dev/null +++ b/marketing/remotion/src/theme.ts @@ -0,0 +1,15 @@ +// audx promo palette — near-monochrome, one accent. No gradients, no glows. +// Inspired by Swiss/Teenage-Engineering restraint: warm off-white on true black, +// a single amber accent for "now" (maximum contrast, minimum palette). +export const COLORS = { + bg: "#0a0a0a", + surface: "#101010", + ink: "#ececea", // warm off-white — text + active cells + inkDim: "#9a9a95", // secondary labels + grey: "#56554f", // tertiary + line: "#23231f", // hairlines, inactive cell outlines + accent: "#d79a4e", // the single accent (warm amber) — used sparingly +} as const; + +export const MONO = + "'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace"; diff --git a/marketing/remotion/tsconfig.json b/marketing/remotion/tsconfig.json new file mode 100644 index 0000000..ecccd7b --- /dev/null +++ b/marketing/remotion/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "ESNext", + "jsx": "react-jsx", + "lib": ["DOM", "ESNext"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src", "remotion.config.ts"] +} diff --git a/package.json b/package.json index a48a823..36871f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audx", - "version": "0.2.0", + "version": "0.3.0", "description": "Terminal-native DAW: pattern sequencing, stem mixing, VST hosting, live coding workflow", "main": "src/audx/cli.py", "bin": { diff --git a/pyproject.toml b/pyproject.toml index 46b0cb0..b1eeeeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "audx" -version = "0.2.0" +version = "0.3.0" description = "Terminal-native DAW: pattern sequencing, stem mixing, live coding workflow" license = "MIT" readme = "README.md" @@ -13,6 +13,7 @@ dependencies = [ "typer>=0.12.0", "rich>=13.0.0", "mido>=1.3.3", + "python-rtmidi>=1.5.0", "python-osc>=1.8.3", "requests>=2.31.0", ] @@ -42,6 +43,7 @@ dev = [ "hypothesis>=6.0", "ruff>=0.3.0", "mypy>=1.8.0", + "types-requests>=2.31.0", ] [tool.ruff] @@ -58,7 +60,11 @@ ignore = ["E501", "B008", "RUF007"] [tool.mypy] python_version = "3.10" -warn_return_any = true +# warn_return_any is intentionally off: numpy's stubs type most array operations +# as returning Any, which floods this audio/DSP codebase with false positives and +# adds no real safety. Every other strict flag stays on, and we now check the +# whole package rather than a handful of files. +warn_return_any = false warn_unused_configs = true check_untyped_defs = true no_implicit_optional = true @@ -66,7 +72,7 @@ warn_redundant_casts = true warn_unused_ignores = true ignore_missing_imports = true follow_imports = "skip" -files = ["src/audx/pattern.py", "src/audx/project.py", "src/audx/sampler.py", "src/audx/cli.py"] +files = ["src/audx"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/make-demo-gif.py b/scripts/make-demo-gif.py new file mode 100644 index 0000000..43a8a32 --- /dev/null +++ b/scripts/make-demo-gif.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Generate an animated terminal GIF of an `audx` session for the README. + +Pure Pillow — no ffmpeg, no browser. Renders a fake-but-faithful terminal +window typing out an audx session in the app's warm palette, then writes an +optimized looping GIF to docs/assets/audx-demo.gif. + +Run: uv run --with pillow python scripts/make-demo-gif.py +""" +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +# ── palette (matches src/audx/config.py THEME) ──────────────────────────────── +BG = (17, 17, 17) +SURFACE = (30, 30, 30) +BAR = (24, 24, 24) +TEXT = (224, 224, 224) +MUTED = (136, 136, 136) +AMBER = (212, 165, 116) +SAGE = (168, 192, 135) +PINK = (232, 166, 194) +RED = (255, 95, 86) + +FONT_DIR = Path("/mnt/skills/examples/canvas-design/canvas-fonts") +FONT = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Regular.ttf"), 22) +FONT_BOLD = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Bold.ttf"), 22) + +W, H = 900, 560 +PAD = 28 +LINE_H = 30 +TOP = 70 # below the title bar + +# Each "line" is a list of (text, color, bold) segments. None = blank line. +Seg = tuple[str, tuple[int, int, int], bool] +SCRIPT: list[list[Seg] | None] = [ + [("$ ", PINK, True), ("pip install audx", TEXT, True)], + [("Successfully installed audx-0.3.0", MUTED, False)], + None, + [("$ ", PINK, True), ("audx demo loop.wav", TEXT, True)], + [(" audx · demo", AMBER, True)], + [(" ♪ kick kick 4/4", MUTED, False)], + [(" ♪ sub sub e(3,8) | tune -5st", MUTED, False)], + [(" ♪ clap clap 2/8", MUTED, False)], + [(" ♪ hats hh 16x8 | swing 12%", MUTED, False)], + [(" ♪ bass bass e(3,8) | tune -7st", MUTED, False)], + [(" ✓ rendered 4 bars @ 124 BPM → loop.wav", SAGE, True)], + None, + [("$ ", PINK, True), ('audx render "cowbell e(5,16,2)"', TEXT, True)], + [("Rendered render.wav", SAGE, False)], + None, + [("$ ", PINK, True), ("# code your music. own your sound.", AMBER, False)], +] + + +def draw_window(visible_lines: list[list[Seg] | None], cursor: bool) -> Image.Image: + img = Image.new("RGB", (W, H), BG) + d = ImageDraw.Draw(img) + + # window body + title bar + d.rounded_rectangle([10, 10, W - 10, H - 10], radius=14, fill=SURFACE) + d.rounded_rectangle([10, 10, W - 10, 50], radius=14, fill=BAR) + d.rectangle([10, 38, W - 10, 50], fill=BAR) + for i, col in enumerate((RED, AMBER, SAGE)): + d.ellipse([34 + i * 26, 23, 48 + i * 26, 37], fill=col) + title = "audx — make a beat in 10 seconds" + tw = d.textlength(title, font=FONT) + d.text(((W - tw) / 2, 18), title, font=FONT, fill=MUTED) + + y = TOP + for line in visible_lines: + if line is not None: + x = PAD + for text, color, bold in line: + f = FONT_BOLD if bold else FONT + d.text((x, y), text, font=f, fill=color) + x += d.textlength(text, font=f) + y += LINE_H + + if cursor and visible_lines: + last = visible_lines[-1] + x = PAD + if last is not None: + for text, _, bold in last: + x += d.textlength(text, font=FONT_BOLD if bold else FONT) + cy = TOP + (len(visible_lines) - 1) * LINE_H + d.rectangle([x + 2, cy + 2, x + 13, cy + 24], fill=AMBER) + return img + + +def main() -> None: + frames: list[Image.Image] = [] + durations: list[int] = [] + shown: list[list[Seg] | None] = [] + + for line in SCRIPT: + if line is None: + shown.append(None) + frames.append(draw_window(shown, cursor=False)) + durations.append(120) + continue + # type the line in progressively (segment by segment for speed/clarity) + partial: list[Seg] = [] + shown.append(partial) + full_text = "".join(seg[0] for seg in line) + built = "" + # character-stagger the merged text but keep segment colours + for seg in line: + text, color, bold = seg + step = max(1, len(text) // 8) + for i in range(step, len(text) + 1, step): + shown[-1] = _slice_segments(line, built + text[:i]) + frames.append(draw_window(shown, cursor=True)) + durations.append(45) + built += text + shown[-1] = list(line) + frames.append(draw_window(shown, cursor=True)) + durations.append(420 if full_text.startswith("$") else 160) + + # hold the final frame + frames.append(draw_window(shown, cursor=True)) + durations.append(2200) + + out = Path("docs/assets/audx-demo.gif") + out.parent.mkdir(parents=True, exist_ok=True) + frames[0].save( + out, + save_all=True, + append_images=frames[1:], + duration=durations, + loop=0, + optimize=True, + disposal=2, + ) + print(f"wrote {out} ({len(frames)} frames, {out.stat().st_size // 1024} KB)") + + +def _slice_segments(line: list[Seg], upto: str) -> list[Seg]: + """Return segments truncated so their concatenation equals ``upto``.""" + result: list[Seg] = [] + remaining = len(upto) + for text, color, bold in line: + if remaining <= 0: + break + take = min(len(text), remaining) + result.append((text[:take], color, bold)) + remaining -= take + return result + + +if __name__ == "__main__": + main() diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..00d0906 --- /dev/null +++ b/site/index.html @@ -0,0 +1,111 @@ + + + + + + audx — code your music, own your sound + + + + +
+ + +
+

Code your music.
Own your sound.

+

+ A terminal-native digital audio workstation. Pattern sequencing, a + built-in synth kit, stem mixing and live-coded playback — calm, local, + hackable. No cloud. No mouse. No lock-in. +

+ +
+
+ + make a beat in 10 seconds +
+
$ pip install audx
+$ audx demo loop.wav
+  audx · demo
+    ♪ kick     kick 4/4
+    ♪ sub      sub e(3,8) | tune -5st
+    ♪ clap     clap 2/8
+    ♪ hats     hh 16x8 | swing 12%
+  ✓ rendered 4 bars @ 124 BPM → loop.wav
+
+

No samples. No audio hardware. No config. Just sound.

+
+
+ +
+
+

Built to feel like an instrument

+
+
+

Pattern DSL

+

Write rhythm as text: kick 4/4, hh 16x8, + Euclidean e(5,16,2), explicit [1.0.1.1] grids.

+
+
+

Built-in synth kit

+

14 procedurally-synthesised drum & perc voices. Zero samples + required — drop in your own audio whenever you like.

+
+
+

16-channel mixer

+

Gain, mute, pan and live level meters. Mix stems the way you'd + expect, driven entirely from the keyboard.

+
+
+

Offline rendering

+

Patterns and stems straight to WAV. No audio device needed — pure + Python and numpy under the hood.

+
+
+

MIDI in & out

+

Export to Standard MIDI File, send clock to external gear, record + incoming MIDI into patterns.

+
+
+

Live-coding workflow

+

.audx projects, A/B/C/D slots, cheap forks, hot-reload + and human-readable diffs.

+
+
+
+ +
+

Speak in patterns

+
+
$ audx render "cowbell e(5,16,2)"
+$ audx render "hh 16x8 | swing 12%"
+$ audx render "sub e(3,8) | tune -5st"
+$ audx synths
+$ audx init my-track --template techno
+
    +
  • vel — level 0.0–1.0
  • +
  • swing — delay odd 16ths
  • +
  • humanize — velocity jitter
  • +
  • chance — per-step probability
  • +
  • tune — ±semitones
  • +
  • pan · gain · channel
  • +
+
+
+ +
+

Open a terminal. Hit play.

+
$ pip install audx && audx demo loop.wav
+ Read the docs on GitHub ↗ +
+
+ + + + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..9b13ca7 --- /dev/null +++ b/site/style.css @@ -0,0 +1,215 @@ +:root { + --primary: #d4a574; /* warm amber */ + --secondary: #a8c087; /* sage green */ + --accent: #e8a6c2; /* muted pink */ + --bg: #111111; + --surface: #1e1e1e; + --surface-2: #181818; + --text: #e0e0e0; + --muted: #888888; + --border: #333333; + --mono: "SF Mono", "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", + Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--mono); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + background-image: + radial-gradient(800px 400px at 80% -10%, rgba(212, 165, 116, 0.10), transparent), + radial-gradient(700px 400px at 0% 10%, rgba(168, 192, 135, 0.06), transparent); +} + +a { color: var(--primary); text-decoration: none; } +a:hover { color: var(--accent); } + +code { + font-family: var(--mono); + color: var(--secondary); + background: rgba(168, 192, 135, 0.08); + padding: 0.05em 0.35em; + border-radius: 4px; + font-size: 0.92em; +} + +/* ── hero ─────────────────────────────────────────────── */ +.hero { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem 1.5rem 4rem; +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0 3rem; +} + +.brand { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--primary); +} +.brand::before { content: "▸ "; color: var(--accent); } + +.nav-link { font-size: 0.95rem; color: var(--muted); } +.nav-link:hover { color: var(--primary); } + +.hero-inner { text-align: center; } + +h1 { + font-size: clamp(2.4rem, 6vw, 4rem); + line-height: 1.1; + letter-spacing: -0.02em; + margin-bottom: 1.25rem; +} +h1 .accent { color: var(--accent); } + +.tagline { + max-width: 620px; + margin: 0 auto 2.5rem; + color: var(--muted); + font-size: 1.05rem; +} + +/* ── terminal ─────────────────────────────────────────── */ +.terminal { + text-align: left; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + max-width: 640px; + margin: 0 auto; +} + +.terminal-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1rem; + background: var(--surface-2); + border-bottom: 1px solid var(--border); +} +.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } +.dot.red { background: #ff5f56; } +.dot.amber { background: var(--primary); } +.dot.green { background: var(--secondary); } +.terminal-title { margin-left: 0.6rem; color: var(--muted); font-size: 0.85rem; } + +.terminal-body { + padding: 1.2rem 1.3rem; + font-size: 0.92rem; + overflow-x: auto; +} +.terminal-body code { background: none; padding: 0; color: var(--text); } + +.prompt { color: var(--accent); } +.cmd { color: var(--text); font-weight: 600; } +.note { color: var(--muted); } +.ok { color: var(--secondary); } + +.subtle { margin-top: 1.5rem; color: var(--muted); font-size: 0.95rem; } + +/* ── sections ─────────────────────────────────────────── */ +main { max-width: 960px; margin: 0 auto; padding: 0 1.5rem; } + +section { padding: 4rem 0; border-top: 1px solid var(--border); } + +h2 { + font-size: 1.7rem; + margin-bottom: 2rem; + text-align: center; + letter-spacing: -0.01em; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.4rem; + transition: border-color 0.2s, transform 0.2s; +} +.card:hover { border-color: var(--primary); transform: translateY(-3px); } +.card h3 { color: var(--primary); margin-bottom: 0.5rem; font-size: 1.1rem; } +.card p { color: var(--muted); font-size: 0.95rem; } + +/* ── showcase ─────────────────────────────────────────── */ +.showcase-grid { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 1.5rem; + align-items: start; +} +.showcase-grid pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.3rem; + overflow-x: auto; + font-size: 0.92rem; +} +.showcase-grid pre code { background: none; padding: 0; } +.ops { list-style: none; } +.ops li { + padding: 0.55rem 0; + border-bottom: 1px dashed var(--border); + color: var(--muted); + font-size: 0.95rem; +} +.ops li:last-child { border-bottom: none; } + +/* ── cta ──────────────────────────────────────────────── */ +.cta { text-align: center; } +.cta-cmd { + display: inline-block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.5rem; + margin: 0 auto 2rem; + font-size: 1rem; +} +.cta-cmd code { background: none; padding: 0; } + +.button { + display: inline-block; + background: var(--primary); + color: #1a1207; + font-weight: 700; + padding: 0.8rem 1.6rem; + border-radius: 8px; + transition: background 0.2s, transform 0.2s; +} +.button:hover { background: var(--accent); color: #1a0712; transform: translateY(-2px); } + +/* ── footer ───────────────────────────────────────────── */ +footer { + max-width: 960px; + margin: 0 auto; + padding: 3rem 1.5rem 4rem; + text-align: center; + border-top: 1px solid var(--border); +} +.philosophy { color: var(--secondary); font-style: italic; margin-bottom: 0.8rem; } +.copyright { color: var(--muted); font-size: 0.9rem; } + +@media (max-width: 640px) { + .showcase-grid { grid-template-columns: 1fr; } +} diff --git a/src/audx/__init__.py b/src/audx/__init__.py index 52118c2..fc4077e 100644 --- a/src/audx/__init__.py +++ b/src/audx/__init__.py @@ -1,2 +1,2 @@ """audx — a classy, terminal-native digital audio workstation.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/audx/ai/groove.py b/src/audx/ai/groove.py index 6b0c5e9..05114fc 100644 --- a/src/audx/ai/groove.py +++ b/src/audx/ai/groove.py @@ -29,10 +29,10 @@ def extract_groove(path: Path, steps: int = 16) -> GrooveProfile | None: if y.ndim > 1: y = y.mean(axis=1) - tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) # type: ignore[attr-defined] bpm = float(tempo) - onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=512) + onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=512) # type: ignore[attr-defined] bar_duration = 60.0 / bpm * 4 step_dur = bar_duration / steps diff --git a/src/audx/arrangement.py b/src/audx/arrangement.py index 337eea9..3111aae 100644 --- a/src/audx/arrangement.py +++ b/src/audx/arrangement.py @@ -3,14 +3,17 @@ from __future__ import annotations import math +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path +from typing import Any import numpy as np import soundfile as sf from audx.pattern import Pattern from audx.sampler import SampleLibrary +from audx.synth import is_synth_voice, synth_voice @dataclass @@ -41,6 +44,154 @@ def total_bars(self) -> int: return max(clip.start_bar + clip.bars for clip in self.clips) +@dataclass +class Section: + """A named chunk of a song — e.g. ``intro``, ``verse``, ``drop``. + + A section bundles one or more :class:`Pattern` objects that all play together + for ``bars`` bars whenever the section appears in a :class:`Song`'s sequence. + """ + + name: str + patterns: list[Pattern] = field(default_factory=list) + bars: int = 4 + + def __post_init__(self) -> None: + self.bars = max(1, int(self.bars)) + + +@dataclass +class Song: + """A multi-section arrangement. + + ``sections`` holds the available named chunks; ``sequence`` names the order + those sections play, by name, so a section can repeat (e.g. + ``["intro", "verse", "drop", "verse", "drop", "outro"]``). + """ + + bpm: float = 128.0 + sections: list[Section] = field(default_factory=list) + sequence: list[str] = field(default_factory=list) + + def add_section(self, section: Section) -> Section: + """Register a section. Returns it for convenient chaining.""" + self.sections.append(section) + return section + + def _section_map(self) -> dict[str, Section]: + return {section.name: section for section in self.sections} + + def timeline(self) -> list[tuple[Section, int]]: + """Resolve ``sequence`` into ``(section, start_bar)`` pairs. + + Sections are laid back-to-back in sequence order, so each entry's start + bar is the cumulative length of everything before it. A name in + ``sequence`` that has no matching section raises ``KeyError``. + """ + by_name = self._section_map() + resolved: list[tuple[Section, int]] = [] + cursor = 0 + for name in self.sequence: + section = by_name.get(name) + if section is None: + raise KeyError( + f"sequence references unknown section {name!r}; " + f"defined sections: {sorted(by_name)}" + ) + resolved.append((section, cursor)) + cursor += section.bars + return resolved + + @property + def total_bars(self) -> int: + """Total length in bars of the whole sequence.""" + by_name = self._section_map() + total = 0 + for name in self.sequence: + section = by_name.get(name) + if section is None: + raise KeyError( + f"sequence references unknown section {name!r}; " + f"defined sections: {sorted(by_name)}" + ) + total += section.bars + return total + + def to_arrangement(self) -> Arrangement: + """Flatten the song into a single bar-based :class:`Arrangement`. + + Every pattern of every sequenced section is placed at its absolute start + bar. Repeated sections produce repeated clips at the right offsets. + """ + arrangement = Arrangement(bpm=self.bpm) + for section, start_bar in self.timeline(): + for pattern in section.patterns: + arrangement.add(pattern, start_bar=start_bar, bars=section.bars) + return arrangement + + @classmethod + def from_spec( + cls, + bpm: float, + sections: Mapping[str, Mapping[str, Any]] | list[Section], + sequence: list[str], + ) -> Song: + """Build a :class:`Song` from a lightweight spec. + + ``sections`` may already be :class:`Section` objects, or a mapping of + ``name -> {"patterns": [...], "bars": int}`` where each pattern is either + a :class:`Pattern` or a ``(name, dsl)`` pair / DSL string. + """ + if isinstance(sections, list): + built = list(sections) + else: + built = [] + for name, spec in sections.items(): + raw_patterns = spec.get("patterns", []) + bars = int(spec.get("bars", 4)) + patterns = [_coerce_pattern(p) for p in raw_patterns] + built.append(Section(name=name, patterns=patterns, bars=bars)) + return cls(bpm=bpm, sections=built, sequence=list(sequence)) + + +def _coerce_pattern(spec: object) -> Pattern: + """Coerce a pattern spec into a parsed :class:`Pattern`.""" + if isinstance(spec, Pattern): + if not spec.steps: + spec.parse_dsl() + return spec + if isinstance(spec, (tuple, list)) and len(spec) == 2: + pattern = Pattern(name=str(spec[0]), dsl=str(spec[1])) + else: + pattern = Pattern(name="pattern", dsl=str(spec)) + pattern.parse_dsl() + return pattern + + +def render_song( + song: Song, + library: SampleLibrary, + output_path: Path, + sample_rate: int = 44100, +) -> Path: + """Render a :class:`Song` to a stereo WAV file. + + The song's sections are laid out at their absolute bar offsets (per + ``sequence``) into one :class:`Arrangement`, then rendered with the exact + same mixing math as :func:`render_arrangement`. Missing/unknown instruments + are skipped, never fatal; an empty song renders a short silent buffer. + """ + arrangement = song.to_arrangement() + mix = _mix_clips( + arrangement.clips, + bpm=song.bpm, + total_bars=song.total_bars, + sample_library=library, + sample_rate=sample_rate, + ) + return _write_mix(mix, output_path, sample_rate) + + def render_arrangement( arrangement: Arrangement, sample_library: SampleLibrary, @@ -52,23 +203,40 @@ def render_arrangement( This is intentionally offline and conservative. Missing samples are skipped rather than crashing, because early audx sessions should stay creative. """ - seconds_per_beat = 60.0 / arrangement.bpm - total_beats = max(4, arrangement.total_bars * 4) + mix = _mix_clips( + arrangement.clips, + bpm=arrangement.bpm, + total_bars=arrangement.total_bars, + sample_library=sample_library, + sample_rate=sample_rate, + ) + return _write_mix(mix, output_path, sample_rate) + + +def _mix_clips( + clips: list[Clip], + bpm: float, + total_bars: int, + sample_library: SampleLibrary, + sample_rate: int, +) -> np.ndarray: + """Mix a list of clips to a stereo ``float32`` buffer (shared by both renderers). + + Missing/unknown instruments are skipped rather than crashing. This is the + single source of truth for audx's offline mixing math so that + :func:`render_arrangement` and :func:`render_song` stay identical. + """ + seconds_per_beat = 60.0 / bpm + total_beats = max(4, total_bars * 4) total_frames = math.ceil(total_beats * seconds_per_beat * sample_rate) mix = np.zeros((total_frames, 2), dtype=np.float32) + synth_cache: dict[str, np.ndarray] = {} - for clip in arrangement.clips: + for clip in clips: for step in clip.pattern.steps: - sample_path = sample_library.resolve(step.sample) - if sample_path is None or not sample_path.exists(): + data = _voice_audio(step, sample_library, sample_rate, synth_cache) + if data is None: continue - data, source_sr = sf.read(str(sample_path), dtype="float32", always_2d=True) - if source_sr != sample_rate: - data = _resample_linear(data, source_sr, sample_rate) - if data.shape[1] == 1: - data = np.repeat(data, 2, axis=1) - else: - data = data[:, :2] for bar in range(clip.start_bar, clip.start_bar + clip.bars): beat = bar * 4 + clip.pattern.swung_beat(step.beat) start = round(beat * seconds_per_beat * sample_rate) @@ -78,14 +246,51 @@ def render_arrangement( gain = step.velocity * 0.7 mix[start:end] += data[: end - start] * gain + return mix + + +def _write_mix(mix: np.ndarray, output_path: Path, sample_rate: int) -> Path: + """Peak-normalise (if clipping) and write a stereo mix to ``output_path``.""" peak = float(np.max(np.abs(mix))) if mix.size else 0.0 if peak > 1.0: - mix /= peak + mix = mix / peak output_path.parent.mkdir(parents=True, exist_ok=True) sf.write(str(output_path), mix, sample_rate) return output_path +def _voice_audio( + step: object, + sample_library: SampleLibrary, + sample_rate: int, + synth_cache: dict[str, np.ndarray], +) -> np.ndarray | None: + """Resolve a step to stereo ``float32`` audio: real sample, else built-in synth. + + Returns ``None`` when the instrument is neither a known sample nor a synth + voice, so the renderer can skip it without crashing. + """ + name = step.sample # type: ignore[attr-defined] + tune = getattr(step, "tune_semitones", 0.0) + sample_path = sample_library.resolve(name) + if sample_path is not None and sample_path.exists(): + data, source_sr = sf.read(str(sample_path), dtype="float32", always_2d=True) + if source_sr != sample_rate: + data = _resample_linear(data, source_sr, sample_rate) + if data.shape[1] == 1: + return np.repeat(data, 2, axis=1) + return data[:, :2] + if is_synth_voice(name): + key = f"{name}:{tune}" + cached = synth_cache.get(key) + if cached is None: + mono = synth_voice(name, sample_rate, tune_semitones=tune) + cached = np.repeat(mono.reshape(-1, 1), 2, axis=1) + synth_cache[key] = cached + return cached + return None + + def _resample_linear(data: np.ndarray, source_sr: int, target_sr: int) -> np.ndarray: if source_sr == target_sr: return data diff --git a/src/audx/calibration.py b/src/audx/calibration.py index 54eb5f2..e0d894a 100644 --- a/src/audx/calibration.py +++ b/src/audx/calibration.py @@ -58,7 +58,7 @@ def measure_impulse(duration_s: float = 1.0, sample_rate: int = 44100) -> float: """ try: import numpy as np - import sounddevice as sd # type: ignore[import-not-found] + import sounddevice as sd except Exception: return 0.0 diff --git a/src/audx/cli.py b/src/audx/cli.py index 77f1f72..b06bea5 100644 --- a/src/audx/cli.py +++ b/src/audx/cli.py @@ -40,6 +40,7 @@ macro_app = typer.Typer(help="Macro registers (vim-style qa…q…@a)") slot_app = typer.Typer(help="Pattern slots A/B/C/D") export_app = typer.Typer(help="Export to other formats") +song_app = typer.Typer(help="Multi-section song arrangement (intro/verse/drop/outro)") app.add_typer(pattern_app, name="pattern") app.add_typer(samples_app, name="samples") app.add_typer(samples_app, name="stems") # spec uses `audx stems` @@ -56,6 +57,7 @@ app.add_typer(macro_app, name="macro") app.add_typer(slot_app, name="slot") app.add_typer(export_app, name="export") +app.add_typer(song_app, name="song") pattern_app.add_typer(slot_app, name="slot") # `audx pattern slot ...` @@ -221,6 +223,135 @@ def doctor() -> None: typer.echo(f"Config dir: {CONFIG_DIR}") +@app.command() +def demo( + output: Path = typer.Argument(Path("audx-demo.wav"), help="Output WAV path"), + bpm: float = typer.Option(124.0, "--bpm", help="Tempo"), + bars: int = typer.Option(4, "--bars", help="Length in bars"), +) -> None: + """Render a full demo beat with the built-in synth kit — zero setup required. + + No samples, no audio device, no config. Proves audx makes music in one command: + + audx demo loop.wav && open loop.wav + """ + from audx.arrangement import Arrangement, render_arrangement + + tracks = [ + ("kick", "kick 4/4 | channel 0"), + ("sub", "sub e(3,8) | channel 1 | vel 0.9 | tune -5st"), + ("clap", "clap 2/8 | channel 2 | vel 0.85"), + ("hats", "hh 16x8 | channel 3 | vel 0.5 | swing 12% | humanize 6%"), + ("openhat", "oh [0.0.1.0.0.0.1.0] | channel 4 | vel 0.4"), + ("perc", "perc e(5,16,2) | channel 5 | vel 0.55"), + ] + arrangement = Arrangement(bpm=bpm) + for name, dsl in tracks: + pattern = Pattern(name=name, dsl=dsl) + pattern.parse_dsl() + arrangement.add(pattern, start_bar=0, bars=bars) + + library = SampleLibrary(SAMPLES_DIR) # empty/missing → synth kit fallback + typer.echo(" audx · demo") + for name, dsl in tracks: + typer.echo(f" ♪ {name:<8} {dsl}") + path = render_arrangement(arrangement, library, output) + typer.echo(f" ✓ rendered {bars} bars @ {bpm:g} BPM → {path}") + + +@app.command() +def jam( + port: str = typer.Option("", "--port", "-p", help="MIDI input port (default: first found)"), + chromatic: bool = typer.Option( + False, "--chromatic", help="Play one melodic voice across the keys (vs. drum pads)" + ), + voice: str = typer.Option("keys", "--voice", help="Melodic voice for --chromatic mode"), + bpm: float = typer.Option(124.0, "--bpm", help="Engine tempo"), + no_lights: bool = typer.Option(False, "--no-lights", help="Don't light Push 2 pads"), +) -> None: + """Play the synth kit live from a MIDI controller or Push 2 — instant sound. + + Drum pads trigger drums (GM note map; every pad makes a sound). On a Push 2 the + pads light up to show the kit and flash when you hit them. Use ``--chromatic`` + to play a melodic voice across a keyboard: + + audx jam # drum pads → kick/snare/hat/... + audx jam --chromatic # keyboard plays the 'keys' voice + audx jam --chromatic --voice bass + """ + from audx.live import run_jam + from audx.midi import list_inputs + from audx.push2 import open_push2_lights, push2_input_name, push2_pad_layout + + inputs = list_inputs() + if not inputs: + typer.echo("No MIDI inputs found. Plug in a controller (or run `audx midi list`).", err=True) + raise typer.Exit(1) + + # Prefer a Push 2 input if one is present and the user didn't pick a port. + in_port = port or push2_input_name() or inputs[0] + typer.echo(f" MIDI in: {in_port}") + + engine = init_engine() + engine.set_bpm(bpm) + try: + engine.start() + except RuntimeError as exc: + typer.echo(f" ✗ {exc}", err=True) + raise typer.Exit(1) from exc + + # Light the Push 2 kit (drums mode only — chromatic doesn't map to the kit). + lights = None + pad_layout = None + if not chromatic and not no_lights: + lights = open_push2_lights() + if lights is not None: + pad_layout = push2_pad_layout() + lights.setup(pad_layout) + typer.echo(" ✓ Push 2 pads lit — hit them!") + + mode = "chromatic" if chromatic else "drums" + typer.echo( + f" ♪ jam mode: {mode}" + (f" ({voice})" if chromatic else " (hit some pads!)") + ) + typer.echo(" Ctrl-C to stop.") + + def _feedback(note: int, v: str, vel: int) -> None: + bar = "█" * max(1, vel // 12) + typer.echo(f" {note:>3} → {v:<8} {bar}") + + try: + run_jam( + engine, + port_name=in_port, + mode=mode, + voice=voice, + on_trigger=_feedback, + pad_layout=pad_layout, + lights=lights, + ) + except KeyboardInterrupt: + pass + finally: + if lights is not None: + lights.close() + engine.stop() + typer.echo("\n stopped.") + + +@app.command() +def synths() -> None: + """List the built-in synth voices (usable in any pattern, no samples needed).""" + from audx.synth import VOICE_ALIASES, list_voices + + typer.echo("Built-in synth voices:") + for voice in list_voices(): + aliases = sorted(a for a, target in VOICE_ALIASES.items() if target == voice and a.strip()) + suffix = f" (aka {', '.join(aliases)})" if aliases else "" + typer.echo(f" · {voice}{suffix}") + typer.echo("\nUse any of these as an instrument, e.g. audx render \"cowbell e(5,8)\"") + + @pattern_app.command("create") def pattern_create( name: str = typer.Argument(..., help="Pattern name"), @@ -326,7 +457,9 @@ def projects_list_flat() -> None: def render_pattern( dsl: str = typer.Argument(..., help="Pattern DSL to render"), output: Path = typer.Option(Path("render.wav"), "--output", "-o", help="Output WAV path"), - sample: Path = typer.Option(..., "--sample", help="Sample file to trigger"), + sample: Path | None = typer.Option( + None, "--sample", help="Sample file to trigger (omit to use the built-in synth kit)" + ), bpm: float = typer.Option(128.0, "--bpm", help="BPM"), bars: int = typer.Option(4, "--bars", help="Number of bars"), stems: bool = typer.Option(False, "--stems", help="Render each pattern to its own WAV"), @@ -334,13 +467,20 @@ def render_pattern( ) -> None: """Render a single pattern to WAV offline. - With ``--variations N`` renders N copies suffixed ``_v01.wav``..``_vNN.wav``. - With ``--stems`` writes one file per active in-process pattern. + With no ``--sample`` the built-in synth kit is used, so ``audx render "kick 4/4"`` + makes sound with zero setup. With ``--variations N`` renders N copies suffixed + ``_v01.wav``..``_vNN.wav``. With ``--stems`` writes one file per active pattern. """ from audx.arrangement import Arrangement, render_arrangement - library = SampleLibrary(sample.parent) - library.build_index(recursive=False) + if sample is not None: + library = SampleLibrary(sample.parent) + library.build_index(recursive=False) + name = sample.stem + else: + # No sample: empty library so the renderer falls back to the synth kit. + library = SampleLibrary(SAMPLES_DIR) + name = dsl.split()[0] if dsl.split() else "kick" if stems: engine = get_pattern_engine() @@ -359,7 +499,7 @@ def render_pattern( if variations and variations > 0: for i in range(1, variations + 1): - pat = Pattern(name=sample.stem, dsl=dsl) + pat = Pattern(name=name, dsl=dsl) pat.parse_dsl() arrangement = Arrangement(bpm=bpm) arrangement.add(pat, start_bar=0, bars=bars) @@ -368,7 +508,7 @@ def render_pattern( typer.echo(f" ✓ {target}") return - pattern = Pattern(name=sample.stem, dsl=dsl) + pattern = Pattern(name=name, dsl=dsl) pattern.parse_dsl() arrangement = Arrangement(bpm=bpm) arrangement.add(pattern, start_bar=0, bars=bars) @@ -376,6 +516,65 @@ def render_pattern( typer.echo(f"Rendered {path}") +def _load_song(spec_path: Path): + """Load a song JSON spec into a Song. Shape: + + {"bpm": 124, "sections": {"intro": {"patterns": ["kick 4/4", ...], "bars": 8}}, + "sequence": ["intro", "verse", "drop", "verse", "drop", "outro"]} + """ + import json + + from audx.arrangement import Song + + data = json.loads(spec_path.read_text()) + return Song.from_spec( + bpm=float(data.get("bpm", 124.0)), + sections=data.get("sections", {}), + sequence=list(data.get("sequence", [])), + ) + + +@song_app.command("render") +def song_render( + spec: Path = typer.Argument(..., help="Song spec JSON (bpm / sections / sequence)"), + output: Path = typer.Option(Path("song.wav"), "--output", "-o", help="Output WAV path"), + sample_rate: int = typer.Option(44100, "--sample-rate", help="Output sample rate"), +) -> None: + """Render a multi-section song to a single WAV (built-in synth kit by default).""" + if not spec.exists(): + typer.echo(f"Song spec not found: {spec}", err=True) + raise typer.Exit(1) + from audx.arrangement import render_song + from audx.sampler import get_sample_library + + try: + song = _load_song(spec) + except (KeyError, ValueError) as exc: + typer.echo(f"Bad song spec: {exc}", err=True) + raise typer.Exit(1) from exc + path = render_song(song, get_sample_library(), output, sample_rate=sample_rate) + typer.echo(f" ✓ rendered {song.total_bars} bars @ {song.bpm:g} BPM → {path}") + + +@song_app.command("info") +def song_info( + spec: Path = typer.Argument(..., help="Song spec JSON"), +) -> None: + """Print the resolved section timeline (section → start bar) and total length.""" + if not spec.exists(): + typer.echo(f"Song spec not found: {spec}", err=True) + raise typer.Exit(1) + try: + song = _load_song(spec) + timeline = song.timeline() + except (KeyError, ValueError) as exc: + typer.echo(f"Bad song spec: {exc}", err=True) + raise typer.Exit(1) from exc + typer.echo(f" song · {song.bpm:g} BPM · {song.total_bars} bars") + for section, start_bar in timeline: + typer.echo(f" bar {start_bar:>3} {section.name:<10} ({section.bars} bars)") + + @app.command("diff") def diff_command( a: Path = typer.Argument(..., help="First .audx project file"), @@ -502,11 +701,11 @@ def track_rm(name: str = typer.Argument(..., help="Track / pattern name")) -> No typer.echo(f" ✓ removed '{name}'" if removed else f" · not found: {name}") -@mix_app.command("set") +@mix_app.command("set", context_settings={"ignore_unknown_options": True}) def mix_set( channel: int = typer.Argument(..., help="Channel index (0-based)"), param: str = typer.Argument(..., help="gain | mute"), - value: str = typer.Argument(..., help="dB for gain, on/off for mute"), + value: str = typer.Argument(..., help="dB for gain (e.g. -3), on/off for mute"), ) -> None: """Set a mixer parameter (spec §06).""" engine = get_engine() or init_engine() @@ -571,6 +770,28 @@ def push2_map() -> None: typer.echo(f"{control.name}\t{control.midi_type}\t{control.number}\t{control.description}") +@push2_app.command("lights") +def push2_lights() -> None: + """Light up the Push 2 drum kit (no audio) — a quick LED check.""" + import time + + from audx.push2 import open_push2_lights, push2_pad_layout + + lights = open_push2_lights() + if lights is None: + typer.echo("No Push 2 output found. Connect it and check `audx midi list`.", err=True) + raise typer.Exit(1) + layout = push2_pad_layout() + lights.setup(layout) + typer.echo(f" ✓ lit {len(layout)} pads. Ctrl-C to clear.") + try: + while True: + time.sleep(0.2) + except KeyboardInterrupt: + lights.close() + typer.echo("\n cleared.") + + @heartmula_app.command("status") def heartmula_status() -> None: """Check whether the local Heartmula bridge is available.""" diff --git a/src/audx/engine.py b/src/audx/engine.py index f16f03b..ae66246 100644 --- a/src/audx/engine.py +++ b/src/audx/engine.py @@ -2,28 +2,38 @@ audx — Core audio engine. Handles: -- Audio backend (sounddevice / CoreAudio) +- Audio backend (sounddevice / CoreAudio), imported lazily - Sample playback voices (memory-mapped WAV/FLAC/MP3) +- Built-in synth voices (procedural, no samples required) - Mix bus (16 channels) - Real-time thread with low-latency callback + +The real-time backend (``sounddevice`` → PortAudio) is imported lazily inside +:meth:`AudioEngine.start`, so importing this module — and everything that builds +on it, including the whole CLI — works on machines without the PortAudio system +library. Offline features (render, export, demo, diff) never touch the backend. """ +from __future__ import annotations + import threading +from typing import Any import numpy as np -import sounddevice as sd import soundfile as sf from audx.pattern import get_pattern_engine from audx.sampler import get_sample_library +from audx.synth import is_synth_voice, synth_voice class AudioEngine: """Main audio engine — runs a real-time audio callback.""" + def __init__(self, sample_rate: int = 48000, buffer_size: int = 256): self.sample_rate = sample_rate self.buffer_size = buffer_size - self.stream: sd.OutputStream | None = None + self.stream: Any = None # sounddevice.OutputStream, created lazily self.running = False self.lock = threading.RLock() self.channels = 16 @@ -33,35 +43,64 @@ def __init__(self, sample_rate: int = 48000, buffer_size: int = 256): self.channel_mute = np.zeros(self.channels, dtype=bool) self.channel_gain = np.ones(self.channels, dtype=np.float32) self.master_level = 1.0 + self.bpm = 128.0 self.scheduler_callback = None self.active_voices: list[Voice] = [] self.pattern_engine = get_pattern_engine() self.sample_library = get_sample_library() - # BPM set from config at runtime # default, will be set from config + self._synth_cache: dict[str, np.ndarray] = {} + @staticmethod + def _backend() -> Any: + """Import the sounddevice backend lazily with a friendly error.""" + try: + import sounddevice as sd + except OSError as exc: # PortAudio missing + raise RuntimeError( + "Real-time audio needs the PortAudio library. Install it " + "(macOS: `brew install portaudio`, Debian/Ubuntu: " + "`apt install libportaudio2`) — offline render/export still work without it." + ) from exc + return sd - def start(self): + def start(self) -> None: if self.stream and self.stream.active: return + sd = self._backend() self.stream = sd.OutputStream( samplerate=self.sample_rate, blocksize=self.buffer_size, channels=2, - dtype='float32', + dtype="float32", callback=self._audio_callback, - finished_callback=self._stream_finished + finished_callback=self._stream_finished, ) self.stream.start() self.running = True - def stop(self): + def stop(self) -> None: if self.stream: self.stream.stop() self.stream.close() self.stream = None self.running = False - def _audio_callback(self, outdata, frames, time_info, status): + def _resolve_voice(self, step: Any, ch: int) -> Voice | None: + """Build a playback voice for a scheduled step: real sample, else synth.""" + sample_path = self.sample_library.resolve(step.sample) + if sample_path and sample_path.exists(): + return SampleVoice(str(sample_path), channel=ch, gain=step.velocity, pan=0.0) + if is_synth_voice(step.sample): + tune = getattr(step, "tune_semitones", 0.0) + key = f"{step.sample}:{self.sample_rate}:{tune}" + buffer = self._synth_cache.get(key) + if buffer is None: + buffer = synth_voice(step.sample, self.sample_rate, tune_semitones=tune) + self._synth_cache[key] = buffer + return SynthVoice(buffer, channel=ch, gain=step.velocity, pan=0.0) + return None + + def _audio_callback(self, outdata: np.ndarray, frames: int, time_info: Any, status: Any) -> None: if status: print(f"[Audio] {status}") self.mix_buffer[:] = 0.0 @@ -69,20 +108,15 @@ def _audio_callback(self, outdata, frames, time_info, status): delta_time = frames / self.sample_rate pattern_steps = self.pattern_engine.tick(delta_time) for step in pattern_steps: - # Resolve sample path using global sample library - sample_path = self.sample_library.resolve(step.sample) - if sample_path and sample_path.exists(): - ch = step.metadata.get('channel', 0) - ch = max(0, min(int(ch), self.channels - 1)) - velocity = step.velocity - voice = SampleVoice(str(sample_path), channel=ch, gain=velocity, pan=0.0) + ch = step.metadata.get("channel", step.channel) + ch = max(0, min(int(ch), self.channels - 1)) + voice = self._resolve_voice(step, ch) + if voice is not None: with self.lock: self.active_voices.append(voice) - else: - print(f"[WARN] Sample not found: {step.sample}") with self.lock: - alive = [] + alive: list[Voice] = [] for voice in self.active_voices: if voice.is_active: samples = voice.generate(frames, self.sample_rate) @@ -93,33 +127,57 @@ def _audio_callback(self, outdata, frames, time_info, status): alive.append(voice) self.active_voices = alive for ch in range(self.channels): - self.channel_levels[ch] = np.sqrt(np.mean(self.mix_buffer[ch]**2)) if frames > 0 else 0.0 + self.channel_levels[ch] = ( + float(np.sqrt(np.mean(self.mix_buffer[ch] ** 2))) if frames > 0 else 0.0 + ) mono = np.sum(self.mix_buffer, axis=0) * self.master_level outdata[:, 0] = mono * 0.7 outdata[:, 1] = mono * 0.7 - def _stream_finished(self): + def _stream_finished(self) -> None: self.running = False - def play_sample(self, sample_path: str, channel: int, volume: float = 1.0, pan: float = 0.0, **kwargs): + def play_sample( + self, + sample_path: str, + channel: int, + volume: float = 1.0, + pan: float = 0.0, + **kwargs: Any, + ) -> SampleVoice: with self.lock: voice = SampleVoice(sample_path, channel, volume, pan, **kwargs) self.active_voices.append(voice) return voice - def set_channel_gain(self, channel: int, gain: float): + def play_synth( + self, + name: str, + channel: int, + volume: float = 1.0, + pan: float = 0.0, + tune_semitones: float = 0.0, + ) -> SynthVoice: + """Trigger a built-in synth voice immediately (used by the TUI keys + jam).""" + buffer = synth_voice(name, self.sample_rate, tune_semitones=tune_semitones) with self.lock: - self.channel_gain[channel] = np.clip(gain, 0.0, 2.0) + voice = SynthVoice(buffer, channel, volume, pan) + self.active_voices.append(voice) + return voice - def set_channel_pan(self, channel: int, pan: float): + def set_channel_gain(self, channel: int, gain: float) -> None: with self.lock: - self.channel_pan[channel] = np.clip(pan, -1, 1) + self.channel_gain[channel] = float(np.clip(gain, 0.0, 2.0)) - def set_master(self, level: float): + def set_channel_pan(self, channel: int, pan: float) -> None: with self.lock: - self.master_level = np.clip(level, 0.0, 2.0) + self.channel_pan[channel] = float(np.clip(pan, -1, 1)) - def set_bpm(self, bpm: float): + def set_master(self, level: float) -> None: + with self.lock: + self.master_level = float(np.clip(level, 0.0, 2.0)) + + def set_bpm(self, bpm: float) -> None: with self.lock: self.bpm = bpm self.pattern_engine.set_bpm(bpm) @@ -141,24 +199,12 @@ def generate(self, frames: int, sr: int) -> np.ndarray: raise NotImplementedError -class SampleVoice(Voice): - def __init__(self, sample_path: str, channel: int, gain: float, pan: float, **kwargs): - super().__init__(channel, gain, pan) - self.sample_path = sample_path - self.loop = kwargs.get('loop', False) - self.start_frame = kwargs.get('start_frame', 0) - try: - self.data, self.sr = sf.read(sample_path, dtype='float32', always_2d=False) - if self.data.ndim > 1: - self.data = np.mean(self.data, axis=1) - self.data = self.data.astype(np.float32) - self.position = self.start_frame - self.length = len(self.data) - print(f" ▶ Playing {sample_path.split('/')[-1]} on ch{channel+1}") - except Exception as e: - print(f" ✗ Failed to load sample: {e}") - self.is_active = False - self.data = np.zeros(1, dtype=np.float32) +class _BufferVoice(Voice): + """Common streaming logic for a fixed mono buffer.""" + + data: np.ndarray + length: int + loop: bool def generate(self, frames: int, sr: int) -> np.ndarray: if not self.is_active: @@ -178,16 +224,48 @@ def generate(self, frames: int, sr: int) -> np.ndarray: return block +class SampleVoice(_BufferVoice): + def __init__(self, sample_path: str, channel: int, gain: float, pan: float, **kwargs: Any): + super().__init__(channel, gain, pan) + self.sample_path = sample_path + self.loop = kwargs.get("loop", False) + self.start_frame = kwargs.get("start_frame", 0) + try: + data, self.sr = sf.read(sample_path, dtype="float32", always_2d=False) + if data.ndim > 1: + data = np.mean(data, axis=1) + self.data = data.astype(np.float32) + self.position = self.start_frame + self.length = len(self.data) + except Exception as e: + print(f" ✗ Failed to load sample: {e}") + self.is_active = False + self.data = np.zeros(1, dtype=np.float32) + self.length = 1 + + +class SynthVoice(_BufferVoice): + """Plays a pre-rendered built-in synth buffer.""" + + def __init__(self, buffer: np.ndarray, channel: int, gain: float = 1.0, pan: float = 0.0): + super().__init__(channel, gain, pan) + self.data = buffer.astype(np.float32) + self.length = len(self.data) + self.loop = False + + _engine: AudioEngine | None = None + def get_engine() -> AudioEngine | None: return _engine -def init_engine(sample_rate=None, buffer_size=None) -> AudioEngine: + +def init_engine(sample_rate: int | None = None, buffer_size: int | None = None) -> AudioEngine: global _engine if _engine is None: from audx.config import AUDX_BPM - # Use defaults if not provided + sr = sample_rate or 48000 bs = buffer_size or 256 _engine = AudioEngine(sr, bs) diff --git a/src/audx/live.py b/src/audx/live.py new file mode 100644 index 0000000..59460ea --- /dev/null +++ b/src/audx/live.py @@ -0,0 +1,136 @@ +"""Live MIDI jam: play the built-in synth kit from a MIDI controller or Push 2. + +This is the real-time "hit a pad, hear a sound" path. Incoming MIDI notes are +mapped to synth voices and triggered on the audio engine immediately, so a kid +mashing pads makes drums instantly — no samples, no project, no setup. + +Two modes: + +* **drums** (default) — General-MIDI percussion notes map to drum voices + (note 36 → kick, 38 → snare, 42 → hi-hat …). Any note that isn't a known GM + drum still makes a sound: it falls back to a drum voice chosen from the note, + so *every* pad on a Push 2's 8x8 grid triggers something. +* **chromatic** — every note plays one melodic voice (``keys`` by default) at the + note's pitch, so a keyboard plays melodies and basslines. + +The mapping functions here are pure and unit-tested; the audio/MIDI loop needs a +real device and is exercised live. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +from audx.synth import SYNTH_VOICES + +# Stable mixer channel per drum voice — matches the TUI finger-drum pads so the +# mixer reads the same whether you play from the keyboard or a controller. +VOICE_CHANNEL: dict[str, int] = { + "kick": 0, + "snare": 1, + "clap": 2, + "hh": 3, + "oh": 4, + "rim": 5, + "tom": 6, + "cowbell": 7, + "perc": 8, + "sub": 9, + "ride": 10, + "crash": 11, + "shaker": 12, +} + +# General-MIDI percussion key map (the de-facto standard most pads/Push send). +GM_DRUM_NOTES: dict[int, str] = { + 35: "kick", 36: "kick", + 37: "rim", 39: "clap", 40: "snare", 38: "snare", + 42: "hh", 44: "hh", 46: "oh", + 41: "tom", 43: "tom", 45: "tom", 47: "tom", 48: "tom", 50: "tom", + 49: "crash", 57: "crash", 52: "crash", 55: "crash", + 51: "ride", 59: "ride", 53: "ride", + 56: "cowbell", + 54: "shaker", 70: "shaker", 69: "shaker", 82: "shaker", + 75: "rim", 76: "perc", 77: "perc", +} + +# Order used to turn any unmapped note into *some* drum, so no pad is silent. +_FALLBACK_DRUMS: tuple[str, ...] = ( + "kick", "snare", "hh", "clap", "oh", "tom", "rim", "perc", "cowbell", "shaker", +) + +CHROMATIC_CENTER = 60 # MIDI middle C == the voice's natural pitch + + +def note_to_drum(note: int) -> tuple[str, int]: + """Map a MIDI note to ``(voice, channel)`` for drums mode (never silent).""" + voice = GM_DRUM_NOTES.get(note) + if voice is None: + voice = _FALLBACK_DRUMS[note % len(_FALLBACK_DRUMS)] + return voice, VOICE_CHANNEL.get(voice, 8) + + +def note_to_chromatic(note: int, voice: str = "keys") -> tuple[str, int, float]: + """Map a MIDI note to ``(voice, channel, tune_semitones)`` for chromatic mode.""" + if voice not in SYNTH_VOICES: + voice = "keys" + return voice, 14, float(note - CHROMATIC_CENTER) + + +def resolve_note( + note: int, mode: str = "drums", voice: str = "keys" +) -> tuple[str, int, float]: + """Unified mapping → ``(voice, channel, tune_semitones)``.""" + if mode == "chromatic": + return note_to_chromatic(note, voice) + v, ch = note_to_drum(note) + return v, ch, 0.0 + + +def run_jam( + engine: object, + port_name: str | None = None, + mode: str = "drums", + voice: str = "keys", + on_trigger: Callable[[int, str, int], None] | None = None, + pad_layout: dict[int, tuple[str, int, tuple[int, int, int]]] | None = None, + lights: object | None = None, +) -> None: + """Open a MIDI input and trigger synth voices live until interrupted. + + ``engine`` must be a started :class:`audx.engine.AudioEngine`. When a + ``pad_layout`` (e.g. a Push 2 kit) is given, notes in it use that voice/channel + and, if ``lights`` is provided, the struck pad is flashed. Raises + ``RuntimeError`` if no MIDI input is available. + """ + import mido + + from audx.midi import list_inputs + + inputs = list_inputs() + if not inputs: + raise RuntimeError("No MIDI input ports found. Connect a controller and retry.") + chosen = port_name if (port_name and port_name in inputs) else inputs[0] + port = mido.open_input(chosen) + try: + while True: + for msg in port.iter_pending(): + if msg.type == "note_on" and msg.velocity > 0: + if pad_layout is not None and msg.note in pad_layout: + v, ch, _rgb = pad_layout[msg.note] + tune = 0.0 + else: + v, ch, tune = resolve_note(msg.note, mode=mode, voice=voice) + engine.play_synth( # type: ignore[attr-defined] + v, ch, volume=msg.velocity / 127.0, tune_semitones=tune + ) + if lights is not None: + lights.flash(msg.note) # type: ignore[attr-defined] + if on_trigger: + on_trigger(msg.note, v, msg.velocity) + if lights is not None: + lights.tick() # type: ignore[attr-defined] + time.sleep(0.002) + finally: + port.close() diff --git a/src/audx/midi.py b/src/audx/midi.py index abcdff7..1e165f5 100644 --- a/src/audx/midi.py +++ b/src/audx/midi.py @@ -5,6 +5,7 @@ import threading import time from dataclasses import dataclass +from typing import Any import mido @@ -62,7 +63,7 @@ def __init__(self, bpm: float = 128.0, port_name: str | None = None) -> None: self.port_name = port_name self._stop = threading.Event() self._thread: threading.Thread | None = None - self.port: object | None = None + self.port: Any = None # mido output port (untyped); created on start() def start(self) -> None: ports = list_outputs() @@ -73,7 +74,7 @@ def start(self) -> None: else: self.port = None return - self.port.send(mido.Message("start")) # type: ignore[union-attr] + self.port.send(mido.Message("start")) self._stop.clear() self._thread = threading.Thread(target=self._tick, daemon=True, name="audx-midi-clock") self._thread.start() @@ -84,8 +85,8 @@ def stop(self) -> None: self._thread.join(timeout=2.0) if self.port is not None: try: - self.port.send(mido.Message("stop")) # type: ignore[union-attr] - self.port.close() # type: ignore[union-attr] + self.port.send(mido.Message("stop")) + self.port.close() except Exception: pass self.port = None @@ -97,7 +98,7 @@ def _tick(self) -> None: while not self._stop.is_set(): try: if self.port is not None: - self.port.send(mido.Message("clock")) # type: ignore[union-attr] + self.port.send(mido.Message("clock")) except Exception: return next_tick += interval diff --git a/src/audx/push2.py b/src/audx/push2.py index 1bb982a..c230351 100644 --- a/src/audx/push2.py +++ b/src/audx/push2.py @@ -1,12 +1,32 @@ -"""Push 2 MIDI mapping scaffold. +"""Push 2 MIDI mapping + pad LED control. -This is not Ableton Push display/LED integration yet. It is a stable map of -useful control names to MIDI notes/CCs so audx can grow without guessing. +Push 2 lights its pads when you send notes *back* to it: a note-on on channel 0 +sets pad ``note`` to the colour at palette index ``velocity``. Exact colours are +defined with a SysEx "set colour palette entry" command and then reapplied. See +Ableton's Push 2 MIDI & Display Interface spec. + +This module exposes: +- the original control-name map (``list_push2_map``), +- a drum-kit pad layout (``push2_pad_layout``) used by ``audx jam``, +- ``Push2Lights`` to paint/flash/clear the pads, +- port discovery helpers. """ from __future__ import annotations +import time from dataclasses import dataclass +from typing import Any + +# Push 2 user SysEx prefix: 00 21 1D (Ableton) 01 (device) 01 (model). +_SYSEX_HEADER = (0x00, 0x21, 0x1D, 0x01, 0x01) +_CMD_SET_PALETTE = 0x03 +_CMD_REAPPLY_PALETTE = 0x05 + +# Pads form an 8x8 grid of notes 36..99; bottom-left = 36, +1 right, +8 up. +PAD_BASE_NOTE = 36 +WHITE_INDEX = 122 # palette slot we set to white for the "hit" flash +OFF_INDEX = 0 @dataclass(frozen=True) @@ -31,3 +51,161 @@ class Push2Control: def list_push2_map() -> list[Push2Control]: return DEFAULT_PUSH2_MAP.copy() + + +# ── drum-kit pad layout ─────────────────────────────────────────────────────── + +# Voices laid left→right along the bottom two rows, each a distinct colour so the +# kit is learnable at a glance. RGB 0..255. +PUSH2_PAD_ORDER: tuple[str, ...] = ( + "kick", "snare", "clap", "hh", "oh", "rim", "tom", + "cowbell", "perc", "sub", "ride", "crash", "shaker", +) +PUSH2_VOICE_COLORS: dict[str, tuple[int, int, int]] = { + "kick": (255, 80, 24), + "snare": (255, 196, 40), + "clap": (255, 64, 156), + "hh": (40, 220, 230), + "oh": (36, 210, 168), + "rim": (170, 230, 64), + "tom": (232, 150, 48), + "cowbell": (220, 200, 60), + "perc": (170, 92, 240), + "sub": (60, 110, 255), + "ride": (120, 184, 255), + "crash": (230, 72, 230), + "shaker": (92, 220, 96), +} + + +def push2_pad_layout() -> dict[int, tuple[str, int, tuple[int, int, int]]]: + """Map Push 2 pad notes → ``(voice, mixer_channel, rgb)`` for the kit.""" + from audx.live import VOICE_CHANNEL + + layout: dict[int, tuple[str, int, tuple[int, int, int]]] = {} + for i, voice in enumerate(PUSH2_PAD_ORDER): + note = PAD_BASE_NOTE + i # 36..48 (bottom two rows) + layout[note] = (voice, VOICE_CHANNEL[voice], PUSH2_VOICE_COLORS[voice]) + return layout + + +# ── port discovery ──────────────────────────────────────────────────────────── + + +def find_push2_port(names: list[str], prefer_user: bool = True) -> str | None: + """Pick a Push 2 port from ``names`` (prefers the 'User' port for LED control).""" + cands = [n for n in names if "push 2" in n.lower()] + if not cands: + return None + if prefer_user: + for n in cands: + if "user" in n.lower(): + return n + return cands[0] + + +def push2_input_name() -> str | None: + import mido + + try: + return find_push2_port(list(mido.get_input_names())) + except Exception: # no MIDI backend (python-rtmidi) installed + return None + + +def open_push2_lights() -> Push2Lights | None: + """Open the Push 2 output for LED control, or ``None`` if not present.""" + import mido + + try: + name = find_push2_port(list(mido.get_output_names())) + if name is None: + return None + port = mido.open_output(name) + except Exception: # no backend / port unavailable + return None + return Push2Lights(port) + + +# ── LED control ─────────────────────────────────────────────────────────────── + + +def _split(value: int) -> tuple[int, int]: + """8-bit colour value → (low 7 bits, high 1 bit) for Push 2 SysEx.""" + value = max(0, min(255, value)) + return value & 0x7F, (value >> 7) & 0x01 + + +class Push2Lights: + """Paint, flash and clear Push 2 pads over MIDI. + + ``port`` is any object with a mido-style ``send`` / ``close`` — real hardware + in use, a fake in tests. + """ + + def __init__(self, port: Any, flash_seconds: float = 0.11): + self._port = port + self._flash_seconds = flash_seconds + self._base: dict[int, int] = {} # pad note → its palette index + self._revert: dict[int, float] = {} # pad note → time to restore colour + + def _sysex(self, *payload: int) -> None: + import mido + + self._port.send(mido.Message("sysex", data=[*_SYSEX_HEADER, *payload])) + + def set_color(self, index: int, rgb: tuple[int, int, int], white: int = 0) -> None: + r, g, b = rgb + self._sysex( + _CMD_SET_PALETTE, index, *_split(r), *_split(g), *_split(b), *_split(white) + ) + + def reapply(self) -> None: + self._sysex(_CMD_REAPPLY_PALETTE) + + def _light(self, note: int, index: int) -> None: + import mido + + self._port.send(mido.Message("note_on", channel=0, note=note, velocity=index)) + + def setup(self, layout: dict[int, tuple[str, int, tuple[int, int, int]]]) -> None: + """Define palette colours for the kit and switch the pads on.""" + self.set_color(OFF_INDEX, (0, 0, 0)) + self.set_color(WHITE_INDEX, (255, 255, 255), white=255) + self._base.clear() + for i, (note, (_voice, _ch, rgb)) in enumerate(sorted(layout.items())): + idx = i + 1 # palette slots 1..N + self.set_color(idx, rgb) + self._base[note] = idx + self.reapply() + for note, idx in self._base.items(): + self._light(note, idx) + + def flash(self, note: int) -> None: + """Briefly light a struck pad white; restored by :meth:`tick`.""" + if note in self._base: + self._light(note, WHITE_INDEX) + self._revert[note] = time.monotonic() + self._flash_seconds + + def tick(self) -> None: + """Restore pads whose flash has elapsed (call frequently).""" + if not self._revert: + return + now = time.monotonic() + for note in [n for n, t in self._revert.items() if now >= t]: + self._light(note, self._base[note]) + del self._revert[note] + + def clear(self) -> None: + for note in self._base: + self._light(note, OFF_INDEX) + self._revert.clear() + + def close(self) -> None: + try: + self.clear() + finally: + try: + self._port.close() + except Exception: + pass diff --git a/src/audx/synth.py b/src/audx/synth.py new file mode 100644 index 0000000..02df69a --- /dev/null +++ b/src/audx/synth.py @@ -0,0 +1,494 @@ +"""Built-in synthesized drum, percussion and melodic voices. + +audx ships a tiny procedural drum machine *and* a handful of tonal voices so it +makes sound with *zero* setup — no sample library required. Everything here is +pure ``numpy`` (no scipy, no audio backend), which keeps it importable on any +machine and fast enough to render arrangements offline. + +Each voice maps a pattern DSL instrument name (``kick``, ``snare``, ``hh`` …) to a +mono ``float32`` one-shot in ``[-1, 1]`` at a requested sample rate. The engine and +the offline renderer both fall back to these voices whenever a sample of the same +name is not found in the user's library, so a brand-new pattern like ``kick 4/4`` +plays immediately. + +Melodic voices +-------------- +Alongside the drums there are pitched voices — ``bass``, ``pluck``, ``stab``, +``keys`` (alias ``ep``), ``saw`` and ``sine`` — so audx can do basslines, stabs +and chords, not just beats. These render at a sensible base pitch (``bass`` near +C2, the rest near C3) and are transposed purely via the existing +``tune_semitones`` parameter, e.g. ``bass e(3,8) | tune 3st``. Their sawtooth +content is band-limited (additive synthesis of a bounded number of harmonics) so +repitching them stays clean rather than aliasing harshly. + +Design notes +------------ +* Deterministic by default (seeded RNG) so renders are reproducible; pass a + ``seed`` to vary the noise on percussive voices. +* Voices are short one-shots; the caller mixes/places them on the timeline. +* ``tune_semitones`` repitches a voice by resampling, so ``kick | tune -2st`` works. +""" + +from __future__ import annotations + +import numpy as np + +__all__ = [ + "SYNTH_VOICES", + "VOICE_ALIASES", + "canonical_voice", + "is_synth_voice", + "list_voices", + "synth_voice", +] + + +# Canonical voice names that the synth knows how to render. +SYNTH_VOICES: tuple[str, ...] = ( + "kick", + "sub", + "snare", + "clap", + "snap", + "hh", + "oh", + "rim", + "tom", + "cowbell", + "perc", + "ride", + "crash", + "shaker", + "bass", + "pluck", + "stab", + "keys", + "saw", + "sine", +) + +# Friendly aliases → canonical voice. Lets people write `bd`, `hat`, `808`, etc. +VOICE_ALIASES: dict[str, str] = { + "bd": "kick", + "bassdrum": "kick", + "808": "sub", + "sd": "snare", + "sn": "snare", + "cp": "clap", + "clp": "clap", + "ch": "hh", + "hat": "hh", + "hats": "hh", + "hihat": "hh", + "closedhat": "hh", + "openhat": "oh", + "ohh": "oh", + "rs": "rim", + "rim shot": "rim", + "rimshot": "rim", + "clave": "rim", + "lt": "tom", + "mt": "tom", + "ht": "tom", + "floortom": "tom", + "cb": "cowbell", + "bell": "cowbell", + "cy": "crash", + "cym": "crash", + "shk": "shaker", + "maraca": "shaker", + "rd": "ride", + "ep": "keys", +} + + +def canonical_voice(name: str) -> str | None: + """Return the canonical synth voice for ``name`` (via alias), or ``None``.""" + key = name.strip().lower() + if key in SYNTH_VOICES: + return key + return VOICE_ALIASES.get(key) + + +def is_synth_voice(name: str) -> bool: + """True if ``name`` (or one of its aliases) is a built-in synth voice.""" + return canonical_voice(name) is not None + + +def list_voices() -> list[str]: + """All canonical voice names, in a musically sensible order.""" + return list(SYNTH_VOICES) + + +# ── envelope / noise helpers ────────────────────────────────────────────────── + + +def _t(seconds: float, sr: int) -> np.ndarray: + """Time axis of ``seconds`` length at sample rate ``sr``.""" + return np.linspace(0.0, seconds, max(1, int(seconds * sr)), endpoint=False, dtype=np.float64) + + +def _exp_env(n: int, decay: float, sr: int, attack: float = 0.002) -> np.ndarray: + """Percussive amp envelope: short linear attack then exponential decay.""" + env = np.exp(-np.arange(n, dtype=np.float64) / max(1.0, decay * sr)) + a = max(1, int(attack * sr)) + if a < n: + env[:a] *= np.linspace(0.0, 1.0, a) + return env + + +def _highpass(x: np.ndarray, amount: float = 0.92) -> np.ndarray: + """Cheap one-pole high-pass (first difference style). amount→1 = brighter.""" + y = np.empty_like(x) + y[0] = x[0] + prev_x = x[0] + prev_y = x[0] + for i in range(1, len(x)): + prev_y = amount * (prev_y + x[i] - prev_x) + prev_x = x[i] + y[i] = prev_y + return y + + +def _lowpass(x: np.ndarray, window: int = 8) -> np.ndarray: + """Cheap moving-average low-pass to tame harsh noise.""" + if window <= 1 or len(x) <= window: + return x + kernel = np.ones(window, dtype=np.float64) / window + return np.convolve(x, kernel, mode="same") + + +def _noise(n: int, rng: np.random.Generator) -> np.ndarray: + return rng.uniform(-1.0, 1.0, n) + + +def _normalize(x: np.ndarray, peak: float = 0.98) -> np.ndarray: + m = float(np.max(np.abs(x))) if x.size else 0.0 + if m > 1e-9: + x = x / m * peak + return x + + +def _bl_saw(freq: float, n: int, sr: int) -> np.ndarray: + """Band-limited sawtooth via additive synthesis. + + Sums harmonics ``sin(k·2π·f·t)/k`` up to just below Nyquist, so the tone has + the bright buzz of a saw without the aliasing of a naive ramp — which matters + because these voices get repitched by resampling. + """ + t = np.arange(n, dtype=np.float64) / sr + out = np.zeros(n, dtype=np.float64) + if freq <= 0.0: + return out + nyquist = sr / 2.0 + max_k = max(1, int(nyquist / freq)) + max_k = min(max_k, 64) # bound the work; plenty of brightness + for k in range(1, max_k + 1): + out += np.sin(2 * np.pi * freq * k * t) / k + # ideal saw is (2/π)·Σ(-1)^(k+1) sin/k; here we keep all-positive phases for a + # consistent bright timbre and just normalise the amplitude below. + return out * (2.0 / np.pi) + + +def _bl_sine(freq: float, n: int, sr: int) -> np.ndarray: + t = np.arange(n, dtype=np.float64) / sr + return np.sin(2 * np.pi * freq * t) + + +# ── individual voices ───────────────────────────────────────────────────────── + + +def _kick(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.34 + t = _t(dur, sr) + n = len(t) + # Pitch sweep 115Hz → 48Hz + f0, f1 = 115.0, 48.0 + pitch = f1 + (f0 - f1) * np.exp(-t / 0.035) + phase = 2 * np.pi * np.cumsum(pitch) / sr + body = np.sin(phase) * _exp_env(n, 0.16, sr) + # Transient click for definition + click = _noise(n, rng) * _exp_env(n, 0.004, sr, attack=0.0005) * 0.4 + return _normalize(body + click) + + +def _sub(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.8 + t = _t(dur, sr) + n = len(t) + f0, f1 = 70.0, 44.0 + pitch = f1 + (f0 - f1) * np.exp(-t / 0.06) + phase = 2 * np.pi * np.cumsum(pitch) / sr + body = np.sin(phase) + # a touch of saturation for an 808 growl + body = np.tanh(body * 1.4) + return _normalize(body * _exp_env(n, 0.34, sr)) + + +def _snare(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.22 + t = _t(dur, sr) + n = len(t) + tone = (np.sin(2 * np.pi * 185 * t) + 0.6 * np.sin(2 * np.pi * 330 * t)) * _exp_env(n, 0.09, sr) + noise = _highpass(_noise(n, rng), 0.86) * _exp_env(n, 0.11, sr) + return _normalize(0.55 * tone + 0.9 * noise) + + +def _clap(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.26 + n = int(dur * sr) + out = np.zeros(n, dtype=np.float64) + base = _highpass(_noise(n, rng), 0.8) + # three fast bursts then a longer tail — the classic clap stack + for offset, decay in ((0.0, 0.012), (0.009, 0.012), (0.018, 0.013), (0.028, 0.07)): + start = int(offset * sr) + if start >= n: + continue + env = _exp_env(n - start, decay, sr, attack=0.0003) + out[start:] += base[: n - start] * env + return _normalize(out) + + +def _snap(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.14 + n = int(dur * sr) + noise = _highpass(_noise(n, rng), 0.9) * _exp_env(n, 0.03, sr, attack=0.0003) + tone = np.sin(2 * np.pi * 1600 * _t(dur, sr)) * _exp_env(n, 0.01, sr) * 0.3 + return _normalize(noise + tone) + + +def _hh(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.05 + n = int(dur * sr) + noise = _highpass(_noise(n, rng), 0.95) * _exp_env(n, 0.014, sr, attack=0.0002) + return _normalize(noise) + + +def _oh(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.35 + n = int(dur * sr) + noise = _highpass(_noise(n, rng), 0.95) * _exp_env(n, 0.16, sr, attack=0.0002) + return _normalize(noise) + + +def _rim(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.06 + t = _t(dur, sr) + n = len(t) + tone = (np.sin(2 * np.pi * 1700 * t) + 0.5 * np.sin(2 * np.pi * 2600 * t)) + return _normalize(tone * _exp_env(n, 0.012, sr, attack=0.0002)) + + +def _tom(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.3 + t = _t(dur, sr) + n = len(t) + f0, f1 = 180.0, 110.0 + pitch = f1 + (f0 - f1) * np.exp(-t / 0.08) + phase = 2 * np.pi * np.cumsum(pitch) / sr + body = np.sin(phase) * _exp_env(n, 0.14, sr) + return _normalize(body) + + +def _cowbell(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.3 + t = _t(dur, sr) + n = len(t) + # two detuned square-ish tones — the 808 cowbell trick + a = np.sign(np.sin(2 * np.pi * 540 * t)) + b = np.sign(np.sin(2 * np.pi * 800 * t)) + tone = _lowpass(0.5 * (a + b), 6) + return _normalize(tone * _exp_env(n, 0.12, sr, attack=0.001)) + + +def _perc(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.16 + t = _t(dur, sr) + n = len(t) + # simple 2-op FM blip for a metallic stab + mod = np.sin(2 * np.pi * 430 * t) * 4.0 + tone = np.sin(2 * np.pi * 720 * t + mod) + return _normalize(tone * _exp_env(n, 0.06, sr, attack=0.001)) + + +def _ride(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.5 + t = _t(dur, sr) + n = len(t) + partials = np.zeros(n, dtype=np.float64) + for f in (520, 1370, 2050, 3400): + partials += np.sin(2 * np.pi * f * t) + shimmer = _highpass(_noise(n, rng), 0.95) * 0.4 + return _normalize((partials / 4 + shimmer) * _exp_env(n, 0.22, sr, attack=0.0005)) + + +def _crash(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.9 + n = int(dur * sr) + noise = _highpass(_noise(n, rng), 0.97) * _exp_env(n, 0.4, sr, attack=0.001) + return _normalize(noise) + + +def _shaker(sr: int, rng: np.random.Generator) -> np.ndarray: + dur = 0.1 + n = int(dur * sr) + noise = _highpass(_noise(n, rng), 0.93) * _exp_env(n, 0.035, sr, attack=0.006) + return _normalize(noise) + + +# ── melodic / tonal voices ──────────────────────────────────────────────────── +# These render at a fixed base pitch and are transposed by ``tune_semitones`` +# via the resampler in ``synth_voice``. Base pitches: bass ≈ C2 (65.4 Hz), the +# rest ≈ C3 (130.8 Hz). Saw content is band-limited so repitching stays clean. + +_C2 = 65.41 +_C3 = 130.81 + + +def _bass(sr: int, rng: np.random.Generator) -> np.ndarray: + """Punchy synth bass: band-limited saw + a sub sine, lowpassed, ~0.5s decay.""" + dur = 0.5 + n = int(dur * sr) + saw = _bl_saw(_C2, n, sr) + sub = _bl_sine(_C2 / 2.0, n, sr) # an octave below for weight + tone = 0.7 * saw + 0.6 * sub + tone = _lowpass(tone, 8) # tame the upper harmonics + tone = np.tanh(tone * 1.2) # gentle drive for punch + env = _exp_env(n, 0.22, sr, attack=0.004) + return _normalize(tone * env) + + +def _pluck(sr: int, rng: np.random.Generator) -> np.ndarray: + """Short plucked synth: fast-decay band-limited saw with a lowpass sweep, ~0.3s.""" + dur = 0.3 + n = int(dur * sr) + saw = _bl_saw(_C3, n, sr) + # cheap downward lowpass sweep: cross-fade a bright tap into a dark tap over + # the note so it starts open and closes as it decays. + bright = _lowpass(saw, 3) + dark = _lowpass(saw, 24) + mix = np.linspace(0.0, 1.0, n) # 0 = bright, 1 = dark + out = (1.0 - mix) * bright + mix * dark + env = _exp_env(n, 0.09, sr, attack=0.002) + return _normalize(out * env) + + +def _stab(sr: int, rng: np.random.Generator) -> np.ndarray: + """Bright detuned-saw minor chord stab (root + min 3rd + 5th), ~0.4s.""" + dur = 0.4 + n = int(dur * sr) + # minor triad rooted at C3: root, minor third (+3 st), fifth (+7 st) + intervals = (0, 3, 7) + chord = np.zeros(n, dtype=np.float64) + for st in intervals: + f = _C3 * (2.0 ** (st / 12.0)) + # two slightly detuned saws per note for width + chord += _bl_saw(f * 0.997, n, sr) + chord += _bl_saw(f * 1.003, n, sr) + chord = _lowpass(chord, 4) + env = _exp_env(n, 0.14, sr, attack=0.004) + return _normalize(chord * env) + + +def _keys(sr: int, rng: np.random.Generator) -> np.ndarray: + """Soft electric-piano-ish tone: sine + a couple of harmonics, gentle attack, ~0.6s.""" + dur = 0.6 + n = int(dur * sr) + f = _C3 + tone = ( + 1.0 * _bl_sine(f, n, sr) + + 0.5 * _bl_sine(f * 2.0, n, sr) + + 0.18 * _bl_sine(f * 3.0, n, sr) + ) + # a faint bell-like attack transient from a higher partial that decays fast + transient = _bl_sine(f * 4.0, n, sr) * _exp_env(n, 0.04, sr, attack=0.0) * 0.25 + env = _exp_env(n, 0.28, sr, attack=0.012) # gentle attack + return _normalize(tone * env + transient * env) + + +def _saw(sr: int, rng: np.random.Generator) -> np.ndarray: + """Raw band-limited sawtooth building block, ~0.5s.""" + dur = 0.5 + n = int(dur * sr) + tone = _bl_saw(_C3, n, sr) + env = _exp_env(n, 0.3, sr, attack=0.006) + return _normalize(tone * env) + + +def _sine(sr: int, rng: np.random.Generator) -> np.ndarray: + """Pure sine tone building block, ~0.5s.""" + dur = 0.5 + n = int(dur * sr) + tone = _bl_sine(_C3, n, sr) + env = _exp_env(n, 0.3, sr, attack=0.006) + return _normalize(tone * env) + + +_RENDERERS = { + "kick": _kick, + "sub": _sub, + "snare": _snare, + "clap": _clap, + "snap": _snap, + "hh": _hh, + "oh": _oh, + "rim": _rim, + "tom": _tom, + "cowbell": _cowbell, + "perc": _perc, + "ride": _ride, + "crash": _crash, + "shaker": _shaker, + "bass": _bass, + "pluck": _pluck, + "stab": _stab, + "keys": _keys, + "saw": _saw, + "sine": _sine, +} + + +def _resample_linear(x: np.ndarray, ratio: float) -> np.ndarray: + """Resample ``x`` by ``ratio`` (>1 = longer/lower) using linear interpolation.""" + if abs(ratio - 1.0) < 1e-6 or len(x) < 2: + return x + target_len = max(1, round(len(x) * ratio)) + old_x = np.linspace(0.0, 1.0, len(x), endpoint=False) + new_x = np.linspace(0.0, 1.0, target_len, endpoint=False) + return np.interp(new_x, old_x, x) + + +def synth_voice( + name: str, + sample_rate: int = 44100, + *, + velocity: float = 1.0, + tune_semitones: float = 0.0, + seed: int | None = 0, +) -> np.ndarray: + """Render a built-in voice to a mono ``float32`` one-shot. + + Parameters + ---------- + name: + Voice or alias (``kick``, ``bd``, ``hh``, ``808`` …). Unknown names raise + ``KeyError`` — call :func:`is_synth_voice` first if unsure. + sample_rate: + Output sample rate in Hz. + velocity: + Linear amplitude scale, clamped to ``[0, 1]``. + tune_semitones: + Repitch the voice. Negative = lower/longer, positive = higher/shorter. + seed: + RNG seed for the noise components. ``None`` = nondeterministic. + """ + voice = canonical_voice(name) + if voice is None: + raise KeyError(f"unknown synth voice: {name!r}") + rng = np.random.default_rng(seed) + buffer = _RENDERERS[voice](sample_rate, rng) + if tune_semitones: + # Lower pitch → longer buffer. ratio = 2**(-semitones/12). + buffer = _resample_linear(buffer, 2.0 ** (-tune_semitones / 12.0)) + buffer = buffer * float(np.clip(velocity, 0.0, 1.0)) + return buffer.astype(np.float32) diff --git a/src/audx/ui/app.py b/src/audx/ui/app.py index 6995a1b..a416456 100644 --- a/src/audx/ui/app.py +++ b/src/audx/ui/app.py @@ -16,6 +16,43 @@ from audx.pattern import get_pattern_engine from audx.project import Project +# ── Live finger-drumming pads ───────────────────────────────────────────────── +# +# Map a keyboard key → (synth voice name, mixer channel). While the TUI is open +# the user can play these built-in synth voices live, like a drum-pad MPC row. +# +# The digit keys 1-9 are already bound (channel mute toggles) and `q`/`t`/`m` +# are bound to quit/tap/mute, so the pads deliberately use a fresh QWERTY-style +# pad block that avoids every existing binding: +# +# w e r kick snare clap (ch 0 1 2) +# a s d f hh oh rim tom (ch 3 4 5 6) +# z x c cowbell perc sub (ch 7 8 9) +# u i o ride crash shaker (ch 10 11 12) +# +# Each value is (voice_name, channel). Keep this a plain module-level dict so it +# can be unit-tested without instantiating the Textual app. +SYNTH_PADS: dict[str, tuple[str, int]] = { + "w": ("kick", 0), + "e": ("snare", 1), + "r": ("clap", 2), + "a": ("hh", 3), + "s": ("oh", 4), + "d": ("rim", 5), + "f": ("tom", 6), + "z": ("cowbell", 7), + "x": ("perc", 8), + "c": ("sub", 9), + "u": ("ride", 10), + "i": ("crash", 11), + "o": ("shaker", 12), +} + + +def pad_for_key(key: str) -> tuple[str, int] | None: + """Return the ``(voice, channel)`` pad for ``key``, or ``None`` if unmapped.""" + return SYNTH_PADS.get(key) + class VUMeter(Static): """Small text VU meter.""" @@ -166,6 +203,15 @@ def render(self) -> str: return f"[{mode}] PAT {slots} · ? help · q quit" +class PadHint(Static): + """Subtle one-line hint listing the live drum-pad keys.""" + + def render(self) -> str: + order = ("w", "e", "r", "a", "s", "d", "f", "z", "x", "c", "u", "i", "o") + pads = " ".join(f"{k}:{SYNTH_PADS[k][0]}" for k in order if k in SYNTH_PADS) + return f"pads {pads}" + + class DAWApp(App): """audx TUI: compact mixer + transport with vim-style modal bindings.""" @@ -179,6 +225,7 @@ class DAWApp(App): PatternRow { height: 3; background: #161616; border: solid #333333; margin: 1; } TransportBar { height: 3; background: #1e1e1e; border: solid #333333; margin: 1; } StatusLine { height: 1; background: #1e1e1e; color: #d4a574; } + PadHint { height: 1; background: #161616; color: #7a8a6a; text-style: dim; } """ TITLE = "audx" @@ -207,6 +254,7 @@ def compose(self) -> ComposeResult: yield ChannelStrip(channel=channel, label=f"{channel + 1:02d}") yield PatternRow() yield TransportBar() + yield PadHint(id="pad-hint") yield StatusLine(id="status") yield Footer() @@ -245,12 +293,28 @@ def update_meters(self) -> None: for channel in range(min(CHANNELS_COUNT, len(levels))): self.query_one(f"#vu-{channel}", VUMeter).level = float(levels[channel]) + def _trigger_pad(self, key: str) -> bool: + """Play the live synth pad bound to ``key``. Returns True if consumed.""" + pad = pad_for_key(key) + if pad is None: + return False + voice, channel = pad + engine = get_engine() or init_engine() + if engine is not None: + engine.play_synth(voice, channel) + return True + def on_key(self, event) -> None: """Modal keymap per spec §04 (NORMAL mode keys).""" key = event.key engine = get_engine() pattern_engine = get_pattern_engine() + # Live finger-drumming: pad keys play a synth voice and are otherwise + # not consumed by any existing binding, so handle them first. + if self._trigger_pad(key): + return + if key == "space": play_button = self.query_one("#play", Button) if str(play_button.label) == "▶": @@ -305,4 +369,4 @@ def run_tui(project: Path | None = None, samples_dir: Path | None = None) -> Non DAWApp(project=project, samples_dir=samples_dir).run() -__all__ = ["DAWApp", "TapTempoCounter", "run_tui"] +__all__ = ["SYNTH_PADS", "DAWApp", "TapTempoCounter", "pad_for_key", "run_tui"] diff --git a/src/audx/voice.py b/src/audx/voice.py index 8bed838..19ce3f8 100644 --- a/src/audx/voice.py +++ b/src/audx/voice.py @@ -23,7 +23,7 @@ def status() -> VoiceStatus: try: import importlib - importlib.import_module("whispercpp") # type: ignore[import-not-found] + importlib.import_module("whispercpp") return VoiceStatus(True, "whispercpp installed") except ImportError: return VoiceStatus(False, "install whispercpp for on-device transcription") diff --git a/src/audx/web.py b/src/audx/web.py index 21beee7..df626cc 100644 --- a/src/audx/web.py +++ b/src/audx/web.py @@ -60,7 +60,7 @@ def _state() -> dict: pe = get_pattern_engine() engine = get_engine() - levels = engine.get_channel_levels() if engine else [] + levels: list[float] = list(engine.get_channel_levels()) if engine else [] return { "bpm": float(pe.bpm), "bar": int(pe.current_bar), diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7761d7a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,130 @@ +"""CLI smoke tests via typer's CliRunner. + +These exercise the offline command surface end to end without an audio device, +which also guards the lazy-PortAudio contract: importing/using the CLI must not +require the real-time backend. +""" + +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf +from typer.testing import CliRunner + +from audx.cli import app +from audx.pattern import get_pattern_engine + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _reset_engine_state(): + """The pattern engine is a process-global singleton; CliRunner shares the + process across tests, so clear it between tests to keep them isolated.""" + get_pattern_engine().patterns.clear() + yield + get_pattern_engine().patterns.clear() + + +def test_version(): + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "audx" in result.stdout + + +def test_synths_lists_voices(): + result = runner.invoke(app, ["synths"]) + assert result.exit_code == 0 + assert "kick" in result.stdout + assert "cowbell" in result.stdout + + +def test_demo_renders_wav(tmp_path: Path): + out = tmp_path / "demo.wav" + result = runner.invoke(app, ["demo", str(out), "--bars", "1"]) + assert result.exit_code == 0, result.stdout + assert out.exists() + data, sr = sf.read(out, always_2d=True) + assert sr == 44100 + assert float(np.max(np.abs(data))) > 0.0 + + +def test_render_without_sample_uses_synth(tmp_path: Path): + out = tmp_path / "r.wav" + result = runner.invoke(app, ["render", "snare 2/8", "-o", str(out), "--bars", "1"]) + assert result.exit_code == 0, result.stdout + assert out.exists() + data, _ = sf.read(out, always_2d=True) + assert float(np.max(np.abs(data))) > 0.0 + + +def test_render_variations(tmp_path: Path): + out = tmp_path / "v.wav" + result = runner.invoke( + app, ["render", "hh 16x8", "-o", str(out), "--variations", "3", "--bars", "1"] + ) + assert result.exit_code == 0, result.stdout + assert (tmp_path / "v_v01.wav").exists() + assert (tmp_path / "v_v03.wav").exists() + + +def test_pattern_create_reports_steps(): + result = runner.invoke(app, ["pattern", "create", "k", "kick 4/4"]) + assert result.exit_code == 0 + assert "4 steps" in result.stdout or "steps" in result.stdout + + +def test_doctor_runs(): + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 0 + assert "audx doctor" in result.stdout + + +def test_init_scaffolds_project(tmp_path: Path): + result = runner.invoke(app, ["init", "myloop", "--parent", str(tmp_path), "--no-git"]) + assert result.exit_code == 0, result.stdout + assert (tmp_path / "myloop" / "project.audx").exists() + assert (tmp_path / "myloop" / "stems").is_dir() + assert (tmp_path / "myloop" / "renders").is_dir() + + +def test_export_midi_requires_patterns(tmp_path: Path): + # fresh process has no in-process patterns -> friendly error, not a crash + result = runner.invoke(app, ["export", "midi", str(tmp_path / "x.mid")]) + assert result.exit_code == 1 + assert not (tmp_path / "x.mid").exists() + + +def test_slot_roundtrip(tmp_path: Path): + runner.invoke(app, ["init", "p", "--parent", str(tmp_path), "--no-git"]) + proj = tmp_path / "p" / "project.audx" + assert proj.exists() + set_result = runner.invoke(app, ["slot", "set", str(proj), "A"]) + assert set_result.exit_code == 0 + list_result = runner.invoke(app, ["slot", "list", str(proj)]) + assert list_result.exit_code == 0 + assert "A" in list_result.stdout + + +def test_diff_reports(tmp_path: Path): + a = tmp_path / "a.audx" + b = tmp_path / "b.audx" + from audx.project import Project + + Project(name="a", bpm=120, patterns=[]).save(a) + Project(name="a", bpm=140, patterns=[]).save(b) + result = runner.invoke(app, ["diff", str(a), str(b)]) + assert result.exit_code == 0 + assert "120" in result.stdout or "140" in result.stdout + + +def test_render_with_real_sample(tmp_path: Path): + sample = tmp_path / "clap.wav" + sf.write(sample, np.ones((80, 1), dtype=np.float32) * 0.3, 44100) + out = tmp_path / "real.wav" + result = runner.invoke( + app, ["render", "clap 4/4", "--sample", str(sample), "-o", str(out), "--bars", "1"] + ) + assert result.exit_code == 0, result.stdout + assert out.exists() diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..51c8272 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,61 @@ +"""Tests for the live MIDI jam mapping (audx/live.py). Pure logic, no device.""" + +import pytest + +from audx.live import ( + GM_DRUM_NOTES, + VOICE_CHANNEL, + note_to_chromatic, + note_to_drum, + resolve_note, +) +from audx.synth import SYNTH_VOICES + + +def test_gm_notes_map_to_real_voices(): + for voice in GM_DRUM_NOTES.values(): + assert voice in SYNTH_VOICES + assert voice in VOICE_CHANNEL + + +@pytest.mark.parametrize("note,voice", [(36, "kick"), (38, "snare"), (42, "hh"), (46, "oh")]) +def test_known_gm_notes(note, voice): + assert note_to_drum(note) == (voice, VOICE_CHANNEL[voice]) + + +def test_no_pad_is_silent(): + # every MIDI note across the full range maps to a valid drum voice/channel + for note in range(128): + voice, channel = note_to_drum(note) + assert voice in SYNTH_VOICES + assert 0 <= channel <= 15 + + +def test_drum_channels_in_range(): + for ch in VOICE_CHANNEL.values(): + assert 0 <= ch <= 15 + + +def test_chromatic_centers_on_middle_c(): + voice, channel, tune = note_to_chromatic(60, "keys") + assert voice == "keys" + assert tune == 0.0 + assert 0 <= channel <= 15 + + +def test_chromatic_transposes(): + _, _, up = note_to_chromatic(72, "bass") # octave up + _, _, down = note_to_chromatic(48, "bass") # octave down + assert up == 12.0 + assert down == -12.0 + + +def test_chromatic_falls_back_for_unknown_voice(): + voice, _, _ = note_to_chromatic(60, "not-a-voice") + assert voice == "keys" + + +def test_resolve_note_modes(): + assert resolve_note(36, mode="drums")[0] == "kick" + v, _ch, tune = resolve_note(64, mode="chromatic", voice="bass") + assert v == "bass" and tune == 4.0 diff --git a/tests/test_more_coverage.py b/tests/test_more_coverage.py new file mode 100644 index 0000000..752b0d1 --- /dev/null +++ b/tests/test_more_coverage.py @@ -0,0 +1,166 @@ +"""Additional coverage: MIDI export, project round-trips, DSL operators, CLI.""" + +from pathlib import Path + +import mido +import pytest +from typer.testing import CliRunner + +from audx.cli import app +from audx.midi_export import patterns_to_midi +from audx.pattern import Pattern, get_pattern_engine +from audx.project import Project, get_template, init_project + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _reset_engine_state(): + get_pattern_engine().patterns.clear() + yield + get_pattern_engine().patterns.clear() + + +# ── MIDI export ──────────────────────────────────────────────────────────────── + + +def test_patterns_to_midi_writes_notes(tmp_path: Path): + pat = Pattern(name="kick", dsl="kick 4/4") + pat.parse_dsl() + out = patterns_to_midi({"kick": pat}, tmp_path / "k.mid", bpm=120, bars=1) + assert out.exists() + mid = mido.MidiFile(out) + note_ons = [m for track in mid.tracks for m in track if m.type == "note_on"] + assert len(note_ons) >= 4 # four-on-the-floor + + +def test_midi_tempo_roundtrip(tmp_path: Path): + pat = Pattern(name="snare", dsl="snare 2/8") + pat.parse_dsl() + out = patterns_to_midi({"snare": pat}, tmp_path / "s.mid", bpm=90) + mid = mido.MidiFile(out) + tempos = [m for track in mid.tracks for m in track if m.type == "set_tempo"] + assert tempos + assert round(mido.tempo2bpm(tempos[0].tempo)) == 90 + + +# ── Project round-trips ───────────────────────────────────────────────────────── + + +def test_project_save_load_roundtrip(tmp_path: Path): + proj = Project(name="demo", bpm=140, patterns=[{"name": "k", "dsl": "kick 4/4"}]) + path = tmp_path / "demo.audx" + proj.save(path) + loaded = Project.load(path) + assert loaded.name == "demo" + assert loaded.bpm == 140 + assert loaded.patterns[0]["name"] == "k" + + +@pytest.mark.parametrize("template", ["empty", "techno", "hip-hop", "demo"]) +def test_templates_build(template): + proj = get_template(template) + assert isinstance(proj, Project) + assert proj.bpm > 0 + + +def test_init_project_layout(tmp_path: Path): + path = init_project(name="song", parent=tmp_path, template="techno", git=False) + assert path.exists() + assert (path.parent / "stems").is_dir() + assert (path.parent / "renders").is_dir() + + +def test_init_project_rejects_duplicate(tmp_path: Path): + init_project(name="dup", parent=tmp_path, template="empty", git=False) + with pytest.raises(FileExistsError): + init_project(name="dup", parent=tmp_path, template="empty", git=False) + + +# ── DSL operators ────────────────────────────────────────────────────────────── + + +def test_euclidean_hit_count(): + pat = Pattern(name="p", dsl="perc e(5,16)") + pat.parse_dsl() + assert len(pat.steps) == 5 + + +def test_euclidean_rotation_changes_first_hit(): + a = Pattern(name="a", dsl="perc e(5,16,0)") + a.parse_dsl() + b = Pattern(name="b", dsl="perc e(5,16,2)") + b.parse_dsl() + assert [s.beat for s in a.steps] != [s.beat for s in b.steps] + + +def test_explicit_grid(): + pat = Pattern(name="c", dsl="clap [1.0.1.0.1.1.0.0]") + pat.parse_dsl() + assert len(pat.steps) == 4 + + +def test_velocity_modifier(): + pat = Pattern(name="h", dsl="hh 16x8 | vel 0.45") + pat.parse_dsl() + assert all(abs(s.velocity - 0.45) < 1e-6 for s in pat.steps) + + +def test_channel_modifier(): + pat = Pattern(name="h", dsl="hh 16x8 | channel 3") + pat.parse_dsl() + assert all(s.channel == 3 for s in pat.steps) + + +def test_swing_offsets_odd_steps(): + pat = Pattern(name="h", dsl="hh 16x8 | swing 50%") + pat.parse_dsl() + assert pat.swing > 0 + + +def test_xrest_grid(): + pat = Pattern(name="g", dsl="x--- -x-- --x- ---x") + pat.parse_dsl() + assert len(pat.steps) == 4 + + +def test_empty_dsl_no_steps(): + pat = Pattern(name="e", dsl="") + pat.parse_dsl() + assert pat.steps == [] + + +# ── More CLI surface ─────────────────────────────────────────────────────────── + + +def test_fork_project(tmp_path: Path): + src = init_project(name="orig", parent=tmp_path, template="empty", git=False) + result = runner.invoke(app, ["fork", str(src.parent), "forked"]) + assert result.exit_code == 0, result.stdout + assert (tmp_path / "forked" / "project.audx").exists() + + +def test_watch_once(tmp_path: Path): + src = init_project(name="w", parent=tmp_path, template="techno", git=False) + result = runner.invoke(app, ["watch", str(src), "--once"]) + assert result.exit_code == 0, result.stdout + + +def test_macro_record_and_replay(): + rec = runner.invoke(app, ["macro", "record", "a", "x j x j"]) + assert rec.exit_code == 0 + rep = runner.invoke(app, ["macro", "replay", "a"]) + assert rep.exit_code == 0 + assert "x j x j" in rep.stdout + + +def test_mix_set_gain(): + result = runner.invoke(app, ["mix", "set", "0", "gain", "-3"]) + assert result.exit_code == 0 + assert "gain" in result.stdout + + +def test_push2_map(): + result = runner.invoke(app, ["push2", "map"]) + assert result.exit_code == 0 + assert result.stdout.strip() diff --git a/tests/test_push2.py b/tests/test_push2.py new file mode 100644 index 0000000..d0c7f44 --- /dev/null +++ b/tests/test_push2.py @@ -0,0 +1,128 @@ +"""Tests for Push 2 pad LED control — validates the SysEx/note bytes vs. the spec.""" + +import pytest + +from audx.live import VOICE_CHANNEL +from audx.push2 import ( + OFF_INDEX, + WHITE_INDEX, + Push2Lights, + _split, + find_push2_port, + push2_pad_layout, +) +from audx.synth import SYNTH_VOICES + + +class FakePort: + """Captures the mido messages a Push2Lights would send to hardware.""" + + def __init__(self): + self.messages = [] + self.closed = False + + def send(self, msg): + self.messages.append(msg) + + def close(self): + self.closed = True + + # convenience views + def sysex(self): + return [list(m.data) for m in self.messages if m.type == "sysex"] + + def notes(self): + return [(m.note, m.velocity, m.channel) for m in self.messages if m.type == "note_on"] + + +def test_split_encoding(): + assert _split(0) == (0, 0) + assert _split(255) == (127, 1) + assert _split(200) == (72, 1) + + +def test_set_color_matches_spec_example(): + # Ableton example: F0 00 21 1D 01 01 03 7D 00 00 00 00 7F 01 7E 00 F7 + # = set palette index 125 to (r=0,g=0,b=255), white=126. + port = FakePort() + Push2Lights(port).set_color(125, (0, 0, 255), white=126) + assert port.sysex() == [ + [0x00, 0x21, 0x1D, 0x01, 0x01, 0x03, 0x7D, 0, 0, 0, 0, 0x7F, 0x01, 0x7E, 0x00] + ] + + +def test_reapply_command_bytes(): + port = FakePort() + Push2Lights(port).reapply() + assert port.sysex() == [[0x00, 0x21, 0x1D, 0x01, 0x01, 0x05]] + + +def test_pad_layout_is_valid(): + layout = push2_pad_layout() + assert len(layout) == 13 + assert set(layout) == set(range(36, 49)) # bottom two rows + for _note, (voice, channel, rgb) in layout.items(): + assert voice in SYNTH_VOICES + assert channel == VOICE_CHANNEL[voice] + assert len(rgb) == 3 and all(0 <= c <= 255 for c in rgb) + + +def test_setup_paints_and_lights_pads(): + port = FakePort() + lights = Push2Lights(port) + lights.setup(push2_pad_layout()) + # one note_on per pad to switch it on, none off yet + notes = port.notes() + assert len(notes) == 13 + assert all(ch == 0 and vel > 0 for _, vel, ch in notes) + + +def test_flash_then_tick_restores(): + port = FakePort() + lights = Push2Lights(port, flash_seconds=0.0) # revert immediately + lights.setup(push2_pad_layout()) + base_count = len(port.notes()) + lights.flash(36) # kick pad + lights.tick() + notes = port.notes() + # flash sent white, tick restored the base colour → two extra note_ons + assert len(notes) == base_count + 2 + assert notes[base_count][1] == WHITE_INDEX + + +def test_flash_ignores_unmapped_pad(): + port = FakePort() + lights = Push2Lights(port) + lights.setup(push2_pad_layout()) + before = len(port.notes()) + lights.flash(99) # not in the kit + assert len(port.notes()) == before + + +def test_clear_turns_pads_off(): + port = FakePort() + lights = Push2Lights(port) + lights.setup(push2_pad_layout()) + port.messages.clear() + lights.clear() + assert all(vel == OFF_INDEX for _, vel, _ in port.notes()) + + +def test_close_clears_and_closes(): + port = FakePort() + lights = Push2Lights(port) + lights.setup(push2_pad_layout()) + lights.close() + assert port.closed + + +@pytest.mark.parametrize( + "names,expected", + [ + (["Ableton Push 2 Live Port", "Ableton Push 2 User Port"], "Ableton Push 2 User Port"), + (["Ableton Push 2"], "Ableton Push 2"), + (["MPK Mini", "Launchkey"], None), + ], +) +def test_find_push2_port_prefers_user(names, expected): + assert find_push2_port(names) == expected diff --git a/tests/test_render_synth.py b/tests/test_render_synth.py new file mode 100644 index 0000000..1147edd --- /dev/null +++ b/tests/test_render_synth.py @@ -0,0 +1,68 @@ +"""Tests for synth-fallback rendering and the demo arrangement.""" + +from pathlib import Path + +import numpy as np +import soundfile as sf + +from audx.arrangement import Arrangement, _voice_audio, render_arrangement +from audx.pattern import Pattern +from audx.sampler import SampleLibrary + + +def _render(dsl: str, tmp_path: Path, name: str = "t", bars: int = 1) -> np.ndarray: + library = SampleLibrary(tmp_path / "empty") # no samples → synth fallback + pattern = Pattern(name=name, dsl=dsl) + pattern.parse_dsl() + arr = Arrangement(bpm=120) + arr.add(pattern, bars=bars) + out = render_arrangement(arr, library, tmp_path / f"{name}.wav") + data, _ = sf.read(out, always_2d=True) + return data + + +def test_synth_render_makes_sound_without_samples(tmp_path: Path): + data = _render("kick 4/4", tmp_path) + assert data.shape[1] == 2 + assert float(np.max(np.abs(data))) > 0.0 + + +def test_unknown_instrument_renders_silence_not_crash(tmp_path: Path): + # 'wobble' is neither a sample nor a synth voice -> skipped, silent, no crash + data = _render("wobble 4/4", tmp_path) + assert float(np.max(np.abs(data))) == 0.0 + + +def test_real_sample_takes_priority_over_synth(tmp_path: Path): + # A file literally named kick.wav should be used instead of the synth. + samples = tmp_path / "samples" + samples.mkdir() + sf.write(samples / "kick.wav", np.ones((50, 1), dtype=np.float32) * 0.5, 44100) + library = SampleLibrary(samples) + library.build_index(recursive=False) + step = Pattern(name="k", dsl="kick 4/4") + step.parse_dsl() + audio = _voice_audio(step.steps[0], library, 44100, {}) + assert audio is not None + # the constant-0.5 sample is flat; a synth kick is not + assert np.allclose(audio, 0.5) + + +def test_voice_audio_returns_none_for_unknown(tmp_path: Path): + library = SampleLibrary(tmp_path) + step = Pattern(name="x", dsl="zonk 4/4") + step.parse_dsl() + assert _voice_audio(step.steps[0], library, 44100, {}) is None + + +def test_synth_cache_reused(tmp_path: Path): + library = SampleLibrary(tmp_path) + pat = Pattern(name="h", dsl="hh 16x8") + pat.parse_dsl() + cache: dict[str, np.ndarray] = {} + first = _voice_audio(pat.steps[0], library, 44100, cache) + assert len(cache) == 1 # one entry for the single voice/tune + second = _voice_audio(pat.steps[1], library, 44100, cache) + # same cached object reused for the same voice/tune + assert first is second + assert len(cache) == 1 diff --git a/tests/test_song.py b/tests/test_song.py new file mode 100644 index 0000000..457e872 --- /dev/null +++ b/tests/test_song.py @@ -0,0 +1,131 @@ +"""Tests for the Song / multi-section arrangement model and rendering.""" + +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +from audx.arrangement import Section, Song, render_song +from audx.pattern import Pattern +from audx.sampler import SampleLibrary + + +def _pattern(name: str, dsl: str) -> Pattern: + pattern = Pattern(name=name, dsl=dsl) + pattern.parse_dsl() + return pattern + + +def _song() -> Song: + intro = Section(name="intro", patterns=[_pattern("hh", "hh 16x8")], bars=2) + verse = Section( + name="verse", + patterns=[_pattern("kick", "kick 4/4"), _pattern("hh", "hh 16x8")], + bars=4, + ) + drop = Section(name="drop", patterns=[_pattern("kick", "kick 4/4")], bars=8) + outro = Section(name="outro", patterns=[_pattern("hh", "hh 8x8")], bars=2) + song = Song(bpm=120, sections=[intro, verse, drop, outro]) + song.sequence = ["intro", "verse", "drop", "verse", "drop", "outro"] + return song + + +def test_add_section_registers_and_returns(): + song = Song(bpm=128) + section = Section(name="intro", bars=4) + returned = song.add_section(section) + assert returned is section + assert song.sections == [section] + + +def test_timeline_resolves_absolute_bar_offsets(): + song = _song() + timeline = song.timeline() + names = [section.name for section, _ in timeline] + offsets = [start for _, start in timeline] + assert names == ["intro", "verse", "drop", "verse", "drop", "outro"] + # intro 2, verse 4, drop 8, verse 4, drop 8, outro 2 + assert offsets == [0, 2, 6, 14, 18, 26] + + +def test_total_bars_matches_sequence_with_repeats(): + song = _song() + assert song.total_bars == 2 + 4 + 8 + 4 + 8 + 2 # == 28 + + +def test_to_arrangement_places_every_pattern_at_offsets(): + song = _song() + arrangement = song.to_arrangement() + # intro:1 + verse:2 + drop:1 + verse:2 + drop:1 + outro:1 patterns + assert len(arrangement.clips) == 8 + assert arrangement.total_bars == 28 + # the verse kick that starts the second verse sits at bar 14 + starts = sorted({clip.start_bar for clip in arrangement.clips}) + assert starts == [0, 2, 6, 14, 18, 26] + + +def test_render_song_writes_stereo_wav_with_matching_duration(tmp_path: Path): + song = _song() + library = SampleLibrary(tmp_path / "empty") # no samples → synth voices + out = render_song(song, library, tmp_path / "song.wav") + assert out.exists() + + data, sr = sf.read(out, always_2d=True) + assert sr == 44100 + assert data.shape[1] == 2 + + seconds_per_beat = 60.0 / song.bpm + expected_beats = song.total_bars * 4 + expected_frames = round(expected_beats * seconds_per_beat * sr) + # frame count tracks total_bars at the given bpm (rounding tolerance) + assert abs(data.shape[0] - expected_frames) <= 2 + # synth voices actually make sound + assert float(np.max(np.abs(data))) > 0.0 + + +def test_render_song_with_unknown_instrument_does_not_crash(tmp_path: Path): + section = Section(name="a", patterns=[_pattern("wobble", "wobble 4/4")], bars=2) + song = Song(bpm=120, sections=[section], sequence=["a"]) + library = SampleLibrary(tmp_path / "empty") + out = render_song(song, library, tmp_path / "silent.wav") + data, _ = sf.read(out, always_2d=True) + assert float(np.max(np.abs(data))) == 0.0 + + +def test_sequence_referencing_undefined_section_raises(): + song = Song(bpm=120, sections=[Section(name="intro", bars=2)]) + song.sequence = ["intro", "chorus"] # 'chorus' not defined + with pytest.raises(KeyError, match="chorus"): + song.timeline() + with pytest.raises(KeyError, match="chorus"): + _ = song.total_bars + + +def test_empty_song_renders_without_crashing(tmp_path: Path): + song = Song(bpm=120) + assert song.total_bars == 0 + assert song.timeline() == [] + library = SampleLibrary(tmp_path / "empty") + out = render_song(song, library, tmp_path / "empty.wav") + data, _ = sf.read(out, always_2d=True) + assert data.shape[1] == 2 + # falls back to the minimum 4-beat silent buffer, no exception + assert float(np.max(np.abs(data))) == 0.0 + + +def test_from_spec_builds_equivalent_song(tmp_path: Path): + song = Song.from_spec( + bpm=120, + sections={ + "intro": {"patterns": [("hh", "hh 16x8")], "bars": 2}, + "drop": {"patterns": [_pattern("kick", "kick 4/4")], "bars": 4}, + }, + sequence=["intro", "drop", "drop"], + ) + assert song.total_bars == 2 + 4 + 4 + assert [s.name for s, _ in song.timeline()] == ["intro", "drop", "drop"] + library = SampleLibrary(tmp_path / "empty") + out = render_song(song, library, tmp_path / "spec.wav") + data, _ = sf.read(out, always_2d=True) + assert float(np.max(np.abs(data))) > 0.0 diff --git a/tests/test_song_cli.py b/tests/test_song_cli.py new file mode 100644 index 0000000..0ac2f2c --- /dev/null +++ b/tests/test_song_cli.py @@ -0,0 +1,58 @@ +"""CLI tests for the multi-section `audx song` commands.""" + +import json +from pathlib import Path + +import numpy as np +import soundfile as sf +from typer.testing import CliRunner + +from audx.cli import app + +runner = CliRunner() + +SPEC = { + "bpm": 120, + "sections": { + "intro": {"patterns": ["hh 16x8"], "bars": 2}, + "drop": {"patterns": ["kick 4/4", "bass e(5,16) | tune -7st"], "bars": 4}, + }, + "sequence": ["intro", "drop", "drop"], +} + + +def _write_spec(tmp_path: Path) -> Path: + p = tmp_path / "song.json" + p.write_text(json.dumps(SPEC)) + return p + + +def test_song_info(tmp_path: Path): + result = runner.invoke(app, ["song", "info", str(_write_spec(tmp_path))]) + assert result.exit_code == 0, result.stdout + assert "10 bars" in result.stdout # 2 + 4 + 4 + assert "intro" in result.stdout and "drop" in result.stdout + + +def test_song_render(tmp_path: Path): + out = tmp_path / "song.wav" + result = runner.invoke(app, ["song", "render", str(_write_spec(tmp_path)), "-o", str(out)]) + assert result.exit_code == 0, result.stdout + assert out.exists() + data, sr = sf.read(out, always_2d=True) + # 10 bars * 4 beats / 120bpm * 0.5 s/beat = 20 s + assert abs(len(data) / sr - 20.0) < 0.5 + assert float(np.max(np.abs(data))) > 0.0 + + +def test_song_missing_spec(tmp_path: Path): + result = runner.invoke(app, ["song", "render", str(tmp_path / "nope.json")]) + assert result.exit_code == 1 + + +def test_song_undefined_section_errors(tmp_path: Path): + bad = dict(SPEC, sequence=["intro", "ghost"]) + p = tmp_path / "bad.json" + p.write_text(json.dumps(bad)) + result = runner.invoke(app, ["song", "info", str(p)]) + assert result.exit_code == 1 diff --git a/tests/test_synth.py b/tests/test_synth.py new file mode 100644 index 0000000..08b2b65 --- /dev/null +++ b/tests/test_synth.py @@ -0,0 +1,88 @@ +"""Tests for the built-in synth kit (audx/synth.py).""" + +import numpy as np +import pytest + +from audx.synth import ( + SYNTH_VOICES, + VOICE_ALIASES, + canonical_voice, + is_synth_voice, + list_voices, + synth_voice, +) + + +def test_list_voices_matches_canonical(): + assert list_voices() == list(SYNTH_VOICES) + assert "kick" in SYNTH_VOICES and "snare" in SYNTH_VOICES + + +@pytest.mark.parametrize("voice", SYNTH_VOICES) +def test_every_voice_renders_audible_buffer(voice): + buf = synth_voice(voice, 44100) + assert buf.dtype == np.float32 + assert buf.ndim == 1 + assert len(buf) > 0 + assert np.isfinite(buf).all() + assert float(np.max(np.abs(buf))) > 0.05 # not silence + assert float(np.max(np.abs(buf))) <= 1.0 # not clipping past full scale + + +@pytest.mark.parametrize("alias,target", list(VOICE_ALIASES.items())) +def test_aliases_resolve(alias, target): + assert canonical_voice(alias) == target + assert is_synth_voice(alias) + + +def test_canonical_and_is_synth_for_unknown(): + assert canonical_voice("definitely-not-a-drum") is None + assert not is_synth_voice("definitely-not-a-drum") + + +def test_case_insensitive(): + assert canonical_voice("KICK") == "kick" + assert canonical_voice("BD") == "kick" + + +def test_velocity_scales_amplitude(): + loud = synth_voice("kick", 44100, velocity=1.0) + quiet = synth_voice("kick", 44100, velocity=0.25) + assert float(np.max(np.abs(quiet))) < float(np.max(np.abs(loud))) + + +def test_velocity_clamped(): + # velocity > 1 is clamped, so it cannot exceed the velocity=1 peak + full = float(np.max(np.abs(synth_voice("snare", 44100, velocity=1.0)))) + over = float(np.max(np.abs(synth_voice("snare", 44100, velocity=5.0)))) + assert over == pytest.approx(full, rel=1e-6) + + +def test_tune_down_lengthens_buffer(): + base = synth_voice("kick", 44100, tune_semitones=0.0) + down = synth_voice("kick", 44100, tune_semitones=-12.0) + up = synth_voice("kick", 44100, tune_semitones=12.0) + assert len(down) > len(base) > len(up) + + +def test_deterministic_with_seed(): + a = synth_voice("hh", 44100, seed=42) + b = synth_voice("hh", 44100, seed=42) + assert np.array_equal(a, b) + + +def test_seed_changes_noise(): + a = synth_voice("hh", 44100, seed=1) + b = synth_voice("hh", 44100, seed=2) + assert not np.array_equal(a, b) + + +def test_sample_rate_affects_length(): + lo = synth_voice("snare", 22050) + hi = synth_voice("snare", 44100) + assert len(hi) > len(lo) + + +def test_unknown_voice_raises(): + with pytest.raises(KeyError): + synth_voice("nonsense", 44100) diff --git a/tests/test_synth_melodic.py b/tests/test_synth_melodic.py new file mode 100644 index 0000000..9db6431 --- /dev/null +++ b/tests/test_synth_melodic.py @@ -0,0 +1,75 @@ +"""Tests for the melodic/tonal synth voices (audx/synth.py).""" + +import numpy as np +import pytest + +from audx.synth import ( + SYNTH_VOICES, + canonical_voice, + is_synth_voice, + synth_voice, +) + +# The pitched voices added on top of the drum kit. +MELODIC_VOICES = ("bass", "pluck", "stab", "keys", "saw", "sine") + + +def test_melodic_voices_registered(): + for voice in MELODIC_VOICES: + assert voice in SYNTH_VOICES + assert is_synth_voice(voice) + + +@pytest.mark.parametrize("voice", MELODIC_VOICES) +def test_every_melodic_voice_renders_audible_buffer(voice): + buf = synth_voice(voice, 44100) + assert buf.dtype == np.float32 + assert buf.ndim == 1 + assert len(buf) > 0 + assert np.isfinite(buf).all() + assert float(np.max(np.abs(buf))) > 0.05 # audible, not silence + assert float(np.max(np.abs(buf))) <= 1.0 # not clipping past full scale + + +@pytest.mark.parametrize("voice", MELODIC_VOICES) +def test_tune_transposes_via_resample(voice): + base = synth_voice(voice, 44100, tune_semitones=0.0) + down = synth_voice(voice, 44100, tune_semitones=-12.0) + up = synth_voice(voice, 44100, tune_semitones=12.0) + # Lower pitch -> longer buffer; higher pitch -> shorter buffer. + assert len(down) > len(base) > len(up) + + +@pytest.mark.parametrize("voice", MELODIC_VOICES) +def test_melodic_voices_deterministic_with_seed(voice): + a = synth_voice(voice, 44100, seed=7) + b = synth_voice(voice, 44100, seed=7) + assert np.array_equal(a, b) + + +def test_bass_is_its_own_voice_not_an_alias(): + # `bass` used to be an alias of `sub`; now it is a distinct canonical voice. + assert canonical_voice("bass") == "bass" + assert "bass" in SYNTH_VOICES + bass = synth_voice("bass", 44100) + sub = synth_voice("sub", 44100) + # Distinct renderers -> distinct buffers (length and/or content differ). + assert not (len(bass) == len(sub) and np.array_equal(bass, sub)) + + +def test_ep_aliases_keys(): + assert canonical_voice("ep") == "keys" + assert is_synth_voice("ep") + assert np.array_equal(synth_voice("ep", 44100), synth_voice("keys", 44100)) + + +def test_808_still_aliases_sub(): + # Removing the `bass` alias must not disturb the other `sub` aliases. + assert canonical_voice("808") == "sub" + + +def test_stab_is_a_chord_not_a_single_tone(): + # A minor triad has more spectral content than a single saw note; sanity-check + # that the stab buffer is not identical to a lone saw of the same length. + stab = synth_voice("stab", 44100) + assert float(np.max(np.abs(stab))) > 0.05 diff --git a/tests/test_ui_keys.py b/tests/test_ui_keys.py new file mode 100644 index 0000000..45ce9bd --- /dev/null +++ b/tests/test_ui_keys.py @@ -0,0 +1,55 @@ +"""Unit tests for the live finger-drumming pad mapping in the TUI. + +These tests exercise the *pure* key→voice mapping only — they never spin up the +Textual event loop, so they stay fast and headless-safe. +""" + +from __future__ import annotations + +from audx.synth import SYNTH_VOICES, is_synth_voice +from audx.ui.app import SYNTH_PADS, pad_for_key + +# Engine channel count (0..15). Mirrors AudioEngine.channels. +MAX_CHANNELS = 16 + + +def test_every_pad_voice_is_a_known_synth_voice() -> None: + for key, (voice, _channel) in SYNTH_PADS.items(): + assert is_synth_voice(voice), f"pad {key!r} maps to unknown voice {voice!r}" + # Pads use canonical voice names, so they should be in SYNTH_VOICES directly. + assert voice in SYNTH_VOICES, f"pad {key!r} voice {voice!r} is not canonical" + + +def test_pad_channels_in_range() -> None: + for key, (_voice, channel) in SYNTH_PADS.items(): + assert isinstance(channel, int), f"pad {key!r} channel must be an int" + assert 0 <= channel < MAX_CHANNELS, f"pad {key!r} channel {channel} out of range" + + +def test_no_duplicate_channels() -> None: + channels = [channel for _voice, channel in SYNTH_PADS.values()] + assert len(channels) == len(set(channels)), "duplicate channel assignments in pads" + + +def test_no_duplicate_keys_or_voices() -> None: + # dict keys are unique by construction; assert voices are distinct too so a + # typo can't silently shadow a pad. + voices = [voice for voice, _channel in SYNTH_PADS.values()] + assert len(voices) == len(set(voices)), "duplicate voice assignments in pads" + + +def test_pads_do_not_collide_with_reserved_keys() -> None: + # Keys already bound in DAWApp.on_key must not be reused as pads. + reserved = {"q", "t", "m", "space", ".", "[", "]", "{", "}"} | set("123456789") + clashes = reserved & set(SYNTH_PADS) + assert not clashes, f"pad keys collide with reserved bindings: {sorted(clashes)}" + + +def test_pad_for_key_resolves_mapped_keys() -> None: + for key, expected in SYNTH_PADS.items(): + assert pad_for_key(key) == expected + + +def test_pad_for_key_unknown_returns_none() -> None: + for key in ("q", "t", "1", "space", "Q", "", "ctrl+c"): + assert pad_for_key(key) is None diff --git a/uv.lock b/uv.lock index 19b9378..967c856 100644 --- a/uv.lock +++ b/uv.lock @@ -91,12 +91,13 @@ wheels = [ [[package]] name = "audx" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "mido" }, { name = "numpy" }, { name = "python-osc" }, + { name = "python-rtmidi" }, { name = "requests" }, { name = "rich" }, { name = "sounddevice" }, @@ -119,6 +120,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "types-requests" }, ] [package.metadata] @@ -127,6 +129,7 @@ requires-dist = [ { name = "mido", specifier = ">=1.3.3" }, { name = "numpy", specifier = ">=1.26.0" }, { name = "python-osc", specifier = ">=1.8.3" }, + { name = "python-rtmidi", specifier = ">=1.5.0" }, { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "sounddevice", specifier = ">=0.4.6" }, @@ -145,6 +148,7 @@ dev = [ { name = "pytest-mock", specifier = ">=3.10" }, { name = "pytest-xdist", specifier = ">=3.0" }, { name = "ruff", specifier = ">=0.3.0" }, + { name = "types-requests", specifier = ">=2.31.0" }, ] [[package]] @@ -1107,6 +1111,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/a2/ce26e437e36707f0da839dd88b072413825ea8986b73ce7b5c4f3cd1b7f4/python_osc-1.10.2-py3-none-any.whl", hash = "sha256:018b28e1cc06427c2c3d695f4e8d87d0caecfe604ff889acc45235cfd94183a2", size = 45467, upload-time = "2026-04-02T20:46:03.718Z" }, ] +[[package]] +name = "python-rtmidi" +version = "1.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/ee/0f91965dcc471714c69df21e5ca3d94dc81411b7dee2d31ff1184bea07c9/python_rtmidi-1.5.8.tar.gz", hash = "sha256:7f9ade68b068ae09000ecb562ae9521da3a234361ad5449e83fc734544d004fa", size = 368130, upload-time = "2023-11-20T21:55:02.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/9c/95c0a6a43bd24a17568e1e31008b1fab7e9a2e54c0ed7301e8d5cc9fa109/python_rtmidi-1.5.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:efc07413b30b0039c0d35abe25a81d740c7405124eb58eed141a8f24388e6fe0", size = 148826, upload-time = "2023-11-20T21:54:20.658Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/8e452d6edc2c04e3407f542d3185c66ffc2d39c8811cf2b117653a0a4d63/python_rtmidi-1.5.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:844bd12840c9d4e03dfc89b2cd57c55dcbf5ed7246504d69c6c661732249b19c", size = 145363, upload-time = "2023-11-20T21:54:23.023Z" }, + { url = "https://files.pythonhosted.org/packages/b5/48/aa1d4924f7aa238a192d69aa565b315af0037f684c9475e8b860c679a655/python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:8bbaf7c7164471712a93ac60c8f9ed146b336a294a5103223bbaf8f10709a0bf", size = 293253, upload-time = "2023-11-20T21:54:24.899Z" }, + { url = "https://files.pythonhosted.org/packages/bf/24/32dc239047a56f44d8d8090d55010f85a38ed959ffe517c2e87a2aa34190/python_rtmidi-1.5.8-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:878ce085dfb65c0974810a7e919f73708cbb4c0430c7924b78f25aea1dd4ebee", size = 304051, upload-time = "2023-11-20T21:54:26.82Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0c/cf771eca1b64610e627ca1e67be8390ecdf5e0e1914efbdd9d50ac4c5986/python_rtmidi-1.5.8-cp310-cp310-win_amd64.whl", hash = "sha256:f2138005c6bd3d8b9af05df383679f6d0827d16056e68a941110732310dcb7dd", size = 132157, upload-time = "2023-11-20T21:54:30.059Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0c/23be16b75c90946784b8d233e61db14cf0482def5396821a1ae0bdcd2739/python_rtmidi-1.5.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30d117193dcad8af67c600c405f53eb096e4ff84849760be14c97270af334922", size = 150205, upload-time = "2023-11-20T21:54:31.849Z" }, + { url = "https://files.pythonhosted.org/packages/4b/12/37d41151b08a292719f05dbeae15475537f8aa291cda34c6634b35916dff/python_rtmidi-1.5.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e234dca7f9d783dd3f1e9c9c5c2f295f02b7af3085301d6eed3b428cf49d327", size = 146737, upload-time = "2023-11-20T21:54:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/b866491545135c699bfbed62f54b93c4d6587afc2bba6e2cbbe898570c32/python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:271d625c489fffb39b3edc5aba67f7c8e29a04a0a0f056ce19e5a888a08b4c59", size = 294847, upload-time = "2023-11-20T21:54:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/a0/79/1ddb4fb1bdb1a8b8bd62007ca4980344a53b7f29633a7bca1088eed964ce/python_rtmidi-1.5.8-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:46bbf32c8a4bf6c8f0df1c02a68689d0757f13cb7a69f27ccbbed3d7b2365918", size = 305433, upload-time = "2023-11-20T21:54:36.322Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/2c55797dbf020d462132d1bc5b34d596b400fa197e2a259b8dd2ea2e5954/python_rtmidi-1.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:cfea32c91752fa7aecfe3d6827535c190ba0e646a9accd6604f4fc70cf4b780f", size = 132937, upload-time = "2023-11-20T21:54:38.3Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/887b0378e0a907489a07bdeb808fa5ed349675245c6ee14d9f6d00304f96/python_rtmidi-1.5.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5443634597eb340cdec0734f76267a827c2d366f00a6f9195141c78828016ac2", size = 158861, upload-time = "2023-11-20T21:54:39.549Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/57cecde253daab896ce53778520cd41eb062641862ebdb0ee6f97511b1d9/python_rtmidi-1.5.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29d9c9d9f82ce679fecad7bb4cb79f3a24574ea84600e377194b4cc1baacec0e", size = 153416, upload-time = "2023-11-20T21:54:40.835Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5b/dc19c53d9d512b74dc2cca3725591cc612b9465645695a0696352a8c8b54/python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:25f5a5db7be98911c41ca5bebb262fcf9a7c89600b88fd3c207ceafd3101e721", size = 305696, upload-time = "2023-11-20T21:54:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/f6/92/5a60f56dfb2740e644e932233928947423cd2101895319b331f84527eb31/python_rtmidi-1.5.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cec30924e305f55284594ccf35a71dee7216fd308dfa2dec1b3ed03e6f243803", size = 315579, upload-time = "2023-11-20T21:54:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/93/46/6af077d262f521ea2bf1ab60b8aad72f34fe6dd55af739176605369d449c/python_rtmidi-1.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:052c89933cae4fca354012d8ca7248f4f9e1e3f062471409d48415a7f7d7e59e", size = 129755, upload-time = "2023-11-20T21:54:44.935Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1616,6 +1643,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"