Writing music audio files using sine, square, triangle, and saw waves.
- Linux/WSL
- Python 3.12+
- uv
- Node.js/npm
- g++
- pybind11 build deps
sudo apt install python3-dev g++ cmake pybind11-devuv sync
uv run setup.py build_ext --inplace
uv run main.pyOpen http://127.0.0.1:8000.
WaveMusic stores projects as JSON files in sheets/:
{
"title": "summer's end",
"key": "e major",
"time_signature": "4/4",
"bpm": 100,
"sample_rate": 44100,
"transpose": 0,
"parts": [
{
"name": "lead",
"timbre": "square",
"score": ["2c4 g e f | 4g 4r"]
}
]
}The score field keeps the compact note syntax. The JSON wrapper stores
project metadata and per-part settings. transpose is a whole-number semitone
offset applied at render time.
timbre can be a simple preset name such as sine, square, triangle,
saw, soft organ, bright organ, reed organ, mellow organ,
string organ, warm synth organ, baroque violin, viola da gamba,
recorder, lute, or harpsichord. Some presets expand to a full render
recipe with partials, filters, and note shaping, while the JSON stays compact.
For custom timbre, use either a mix of the four base waves or additive partials:
{
"name": "lead",
"timbre": {
"preset": "custom",
"mix": {
"sine": 0.4,
"square": 0.15,
"triangle": 0.3,
"saw": 0.15
}
},
"score": ["2c4 d e f | 4g r"]
}{
"name": "lead",
"timbre": {
"preset": "custom",
"partials": [1.0, 0.55, 0.35, 0.25],
"filter": {
"highpass": 120,
"lowpass": 4500
},
"noise": 0.02,
"envelope": {
"attack_ms": 25,
"decay_ms": 60,
"sustain": 0.9,
"release_ms": 120
},
"vibrato": {
"depth": 0
}
},
"score": ["2c4 d e f | 4g r"]
}See docs/json-score-format.md for the full JSON
schema, note syntax, and barline convention. See
docs/ai-generation-prompt.md for the AI prompt,
and docs/timbre.md for presets, mix and partials timbre
format, filter visualization, noise, envelope, and vibrato.
JSON is the project format because it is explicit, easy to validate, and maps
directly to the web UI. It is also a better target for AI-generated music:
future prompt-based generation can ask a model for one JSON object with
title, key, time_signature, bpm, sample_rate, transpose, and
parts, while the model only needs to learn WaveMusic's compact note syntax
inside each score field.
Before rendering, Python converts the JSON project into an internal .score
stream for the C++ engine. The .score format contains only render data, such
as mix sections and notes; metadata like title, key, and
time_signature stays in JSON unless it later affects sound generation. This
keeps the C++ code focused on the heavy work: parsing a strict render stream,
synthesizing audio, mixing parts, filtering, and writing WAV output. Because
.score is generated by Python rather than edited directly by users, the Python
conversion layer should validate messy user or AI input and emit a strict,
predictable .score stream.
See docs/score-format.md for the internal .score
syntax contract.
- C++ audio engine
- Python web/API layer
- TypeScript web frontend
Architecture of audio engine
main()
┌──────────────────────────┐
│ │
│ Write WAV │
│ header │
sigen.cpp │ │
│ │ │
┌──────────────────────────┐ │ ▼ │
│ │ │ │
│ play() Generate │ │ Parse parse() │
│ signals ◄─┼─┼── score │
│ │ │ │
│ │ │ │ │ │
│ ▼ │ │ │ │
│ │ │ │ │
│ envelope_ ADSR-style │ │ │ │
│ gain() envelope │ │ │ │
│ │ │ │ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ │ │ │
│ highpass/ Optional ──┼─┼─► Write │
│ lowpass filters │ │ notes │
│ │ │ │
└──────────────────────────┘ │ │ │
│ ▼ │
│ │
│ Insert data size │
│ in WAV header │
│ │
│ │ │
│ ▼ │
│ │
│ Playback │
│ with │
│ system Call │
│ │
└──────────────────────────┘
To build and run directly:
./simple tests/fixtures/score/basic_triangle.scorefor debug mode:
make refresh DEBUG=1
./simple tests/fixtures/score/basic_triangle.score --no-playFull flag example:
./simple \
--score=tests/fixtures/score/basic_triangle.score \
--out=/tmp/basic_triangle.wav \
--sample-rate=44100 \
--bpm=100 \
--transpose=0 \
--no-playFlags:
--score=<path>: input.scorefile. A positional score path also works.--out=<path>: output WAV path. If omitted, writesm.wav.--sample-rate=<hz>: output sample rate. Defaults to44100.--bpm=<beats>: tempo. Defaults to100.--transpose=<semitones>: whole-number pitch shift in semitones. Defaults to0.--no-playor--silent: render the WAV without starting playback.
The C++ executable renders the internal score stream. Normal project files are
JSON and should be rendered through main.py or the web app. The .score
fixtures under tests/fixtures/score/ should strictly follow
docs/score-format.md, because .score is the narrow
format parsed by the C++ engine. BPM, sample rate, and transpose are runtime
flags, not .score syntax.
When rendering a JSON project through main.py, Python converts it to a
temporary .score file before calling the C++ engine. The CLI uses a unique
path like /tmp/wavemusic-*/main.score while rendering and then removes it, so
two renders cannot overwrite the same intermediate file. The web API keeps
generated intermediates under /tmp/wavemusic/ by default, or under
WAVEMUSIC_GENERATED_DIR if that environment variable is set.
To inspect or reuse a stable intermediate .score file, write one explicitly:
uv run python - <<'PY' > /tmp/wavemusic-main.score
from pathlib import Path
from scripts.project import compose_score, load_project
project = load_project(Path("sheets/pokemoncentre.json"))
print(compose_score(project["parts"]))
PY
./simple /tmp/wavemusic-main.score --out=/tmp/pokemoncentre.wav --no-playUses FastAPI, pybind.
The wave module in the Python standard library provides a convenient interface to the WAV sound format. References: https://docs.python.org/3/library/wave.html https://www.tutorialspoint.com/read-and-write-wav-files-using-python-wave
To run the python script:
uv run main.pyTo use command-line interface:
uv run main.py cliTo generate WAV from a JSON project:
uv run main.py sheets/<title>.jsonRun the app:
uv run main.pyThen open http://127.0.0.1:8000.
This starts the Python API and serves the built TypeScript frontend from the
same local server. The first run builds the frontend automatically if
webapp/dist does not exist.
For frontend development, run the API in one terminal and Vite in another:
uv run uvicorn scripts.web_api:app --host 127.0.0.1 --port 8000 --reload
cd webapp && npm run devThen open http://127.0.0.1:5173.
Recommended public demo setup:
- Backend: deploy the FastAPI/C++ engine container to Google Cloud Run.
- Frontend: deploy the static TypeScript build to GitHub Pages.
- Local mode: keep using
uv run main.py; it serves the API and frontend from one local server.
Deployment guides:
TODO list:
- time different implementations python vs c++. Additive partials take much more time to compute. Use wavetable/cache?
- write tests
- add docs
- use () to pass frequecies or chords
- add loudness: [ff], [f], [fp], [p], [pp]
- add dynamics: cresc, dim
- functional REPL: modernize
uv run main.py clias terminal interactive score preview