feat: add minimal Gradio web UI (app.py)

Single-page form wrapping src.generate.generate_period: pick model, mode,
key, style, function, time, sampling params and optional prefix; returns
the chord grid plus downloadable .chord and .mid files. Russian usage
instructions are embedded on the same page.

Auto-length output is capped at 16 bars (the period maximum) so a model
that never emits EOS can't run away into dozens of NC/hold bars.

Added per the author's explicit request — web UI was previously out of
scope; updated CLAUDE.md and README accordingly. Choices for style/
function/time are derived from VOCAB so the form can't drift from the
tokenizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:38:01 +03:00
parent f00a6c1b3a
commit c147c47acb
4 changed files with 353 additions and 4 deletions
+2 -2
View File
@@ -158,7 +158,7 @@ When generating commit messages or code comments, write in English. When generat
- **Do not change the `.chord` format** without first updating `docs/chord_format_spec.md` and bumping its version number. The format is the contract between the human-readable data and the model; changing one side silently breaks everything.
- **Do not modify files in `data/holdout/` or use them during training.** Holdout is held out.
- **Do not add new model architectures "to compare"** unless explicitly asked. One model, done well, beats four half-done.
- **Do not implement bells and whistles** (web UI, real-time audio synthesis, beam search, voicing models). They are explicitly out of scope.
- **Do not implement bells and whistles** (real-time audio synthesis, beam search, voicing models). They are explicitly out of scope. (Exception: a minimal Gradio web UI in `app.py` was added at the author's explicit request — maintain it, but do not expand it into a full editor/DAW.)
- **Do not silently round or coerce unrecognized chord symbols.** If a chord can't be parsed, raise an error with the file name, bar number, and position. Silent corruption of training data is the worst failure mode here.
## Things to always do
@@ -175,7 +175,7 @@ When generating commit messages or code comments, write in English. When generat
- Voice leading / voicing inside chords above the bass
- Rhythmic patterns inside a held chord
- Arrangement, timbre, dynamics
- Web interface / GUI
- Web interface / GUI beyond the minimal Gradio form in `app.py` (added by explicit request; no notation editor, playback, or version history)
- Real-time MIDI integration with REAPER
- Modulation handling inside a single period
- J-Pop fine-tuning experiment (future work after coursework deadline)
+29 -2
View File
@@ -22,6 +22,7 @@
- [3. Установка](#3-установка)
- [4. Структура репозитория](#4-структура-репозитория)
- [5. Быстрый старт](#5-быстрый-старт)
- [5.1 Веб-интерфейс](#51-веб-интерфейс)
- [6. Подготовка датасета](#6-подготовка-датасета)
- [6.1 Собственный корпус](#61-собственный-корпус)
- [6.2 Публичный корпус](#62-публичный-корпус)
@@ -169,6 +170,31 @@ python scripts/generate.py \
Модель достроит остаток периода в логике, выученной на собственном корпусе
автора.
### 5.1 Веб-интерфейс
Для интерактивной работы без командной строки предусмотрен простой
веб-интерфейс на базе Gradio. Запуск:
```bash
python app.py
```
После запуска интерфейс открывается в браузере по адресу
`http://127.0.0.1:7860`. На одной странице расположены форма выбора
параметров (модель, лад, тональность, стиль, функция, тактовый размер,
параметры сэмплирования, опциональный префикс) и блок результата —
текстовая сетка аккордов и кнопки скачивания `.chord`- и `.mid`-файлов.
Краткая инструкция на русском языке встроена в саму страницу (раздел
«Инструкция»).
Интерфейс является надстройкой над тем же модулем `src/generate.py`,
что и CLI; вся логика генерации общая. Полезные флаги:
```bash
python app.py --port 8000 # другой порт
python app.py --share # временная публичная ссылка
```
## 6. Подготовка датасета
Подготовка датасета — самая трудозатратная часть проекта (10–15 часов
@@ -340,8 +366,9 @@ python scripts/evaluate.py \
- Ритмический паттерн внутри удержания аккорда (синкопы, проходящие фигуры,
альбертиевы басы).
- Аранжировка, тембр, динамика, артикуляция.
- Графический пользовательский интерфейс. Взаимодействие осуществляется
через командную строку.
- Развитый графический интерфейс. Реализован лишь минимальный веб-интерфейс
(см. раздел 5.1) как надстройка над CLI; полноценного редактора с
визуализацией нотного стана, проигрывателем и историей версий нет.
- Прямая интеграция с REAPER в режиме реального времени. Обмен с DAW
происходит через файлы MIDI.
- Обработка модуляций внутри одного периода. При наличии модуляции в
+319
View File
@@ -0,0 +1,319 @@
"""Minimal Gradio web UI for hamori — generate a harmonic period from a browser.
A single-page form that wraps :func:`src.generate.generate_period`. Pick the
mode, key, style and sampling parameters; the app returns the chord grid plus
downloadable ``.chord`` and ``.mid`` files. Russian usage instructions are
embedded on the same page (see the "Инструкция" accordion).
This is a convenience front-end for demonstration only — all generation logic
lives in ``src/``. The CLI (``scripts/generate.py``) remains the canonical
entry point.
Usage:
python app.py # serve on http://127.0.0.1:7860
python app.py --share # also create a temporary public link
python app.py --port 8000
"""
from __future__ import annotations
import argparse
import tempfile
from dataclasses import replace
from functools import lru_cache
from pathlib import Path
from uuid import uuid4
import gradio as gr
import torch
from src.generate import generate_period
from src.midi_export import chord_file_to_midi
from src.model import ChordTransformer
from src.tokenizer import VOCAB, write_chord_file
# ---------------------------------------------------------------------------
# Constants — choices derived from the vocabulary so the form never drifts.
# ---------------------------------------------------------------------------
CHECKPOINT_DIR = Path(__file__).resolve().parent / "checkpoints"
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# A period is 416 bars by definition; cap runaway "auto" output at this ceiling.
MAX_PERIOD_BARS = 16
STYLES = [t[len("STYLE_"):] for t in VOCAB if t.startswith("STYLE_")]
FUNCTIONS = [t[len("FUNC_"):] for t in VOCAB if t.startswith("FUNC_")]
TIMES = [t[len("TIME_"):] for t in VOCAB if t.startswith("TIME_")]
# Files generated for download live here for the lifetime of the process.
OUTPUT_DIR = Path(tempfile.mkdtemp(prefix="hamori_webui_"))
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# ---------------------------------------------------------------------------
# Model loading (cached per checkpoint)
# ---------------------------------------------------------------------------
@lru_cache(maxsize=2)
def _load_model(checkpoint: str) -> ChordTransformer:
"""Load and cache a ChordTransformer by checkpoint stem ('pretrained' / 'finetuned')."""
path = CHECKPOINT_DIR / f"{checkpoint}.pt"
ckpt = torch.load(path, map_location=DEVICE, weights_only=True)
model = ChordTransformer(**ckpt["model_config"])
model.load_state_dict(ckpt["model_state"])
model.to(DEVICE)
model.eval()
return model
def _available_checkpoints() -> list[str]:
return sorted(p.stem for p in CHECKPOINT_DIR.glob("*.pt"))
# ---------------------------------------------------------------------------
# Generation callback
# ---------------------------------------------------------------------------
def _format_bars(period) -> str:
"""Render the bar grid as aligned text, one bar per line."""
width = max((len(s) for bar in period.bars for s in bar), default=1)
lines = []
for i, bar in enumerate(period.bars, 1):
cells = " ".join(s.rjust(width) for s in bar)
lines.append(f"Bar {i:2d} | {cells}")
return "\n".join(lines)
def generate(
checkpoint: str,
mode: str,
key: str,
style: str,
function: str,
time: str,
subdivision: int,
auto_bars: bool,
n_bars: int,
temperature: float,
top_p: float,
repetition_penalty: float,
tonic_anchor: bool,
prefix_text: str,
seed,
tempo: int,
):
"""Run one generation and return (status, bar grid, .chord path, .mid path)."""
try:
model = _load_model(checkpoint)
except Exception as exc: # noqa: BLE001 — surface any load error to the UI
return f"❌ Не удалось загрузить чекпойнт «{checkpoint}»: {exc}", "", None, None
target_key = f"{key}_{mode}"
# Prefix: explicit text wins; otherwise optionally anchor to the tonic.
prefix_chords: list[str] | None
prefix_text = (prefix_text or "").strip()
if prefix_text:
prefix_chords = prefix_text.split()
elif tonic_anchor:
prefix_chords = [key + ("m" if mode == "minor" else "")]
else:
prefix_chords = None
seed_val = int(seed) if seed is not None else None
bars_arg = None if auto_bars else int(n_bars)
try:
period = generate_period(
model=model,
mode=mode,
time=time,
subdivision=int(subdivision),
style=style,
function=function,
key=target_key,
prefix=prefix_chords,
temperature=float(temperature),
top_p=float(top_p),
n_bars=bars_arg,
seed=seed_val,
repetition_penalty=float(repetition_penalty),
)
except Exception as exc: # noqa: BLE001 — show generation errors verbatim
return f"❌ Ошибка генерации: {exc}", "", None, None
# "Auto" lets the model close via EOS, but if it never does it can run away
# into dozens of NC/hold bars. A period is 416 bars — cap the tail.
truncated = False
if len(period.bars) > MAX_PERIOD_BARS:
period = replace(period, bars=period.bars[:MAX_PERIOD_BARS])
truncated = True
period = replace(period, title=f"hamori — {key} {mode}, {function}")
stem = f"hamori_{key.replace('#', 'sharp')}_{mode}_{uuid4().hex[:6]}"
chord_path = OUTPUT_DIR / f"{stem}.chord"
midi_path = OUTPUT_DIR / f"{stem}.mid"
write_chord_file(period, chord_path)
chord_file_to_midi(chord_path, midi_path, tempo=int(tempo))
status = (
f"✅ Готово — {len(period.bars)} тактов · {target_key} · "
f"модель: {checkpoint} · seed: {seed_val if seed_val is not None else 'random'}"
)
if truncated:
status += f" · обрезано до {MAX_PERIOD_BARS} тактов (период ≤ 16)"
return status, _format_bars(period), str(chord_path), str(midi_path)
# ---------------------------------------------------------------------------
# Russian instructions (rendered inline)
# ---------------------------------------------------------------------------
INSTRUCTIONS_RU = """
## Как пользоваться
**hamori** генерирует одну гармоническую фразу (период, 4–16 тактов) в заданной
тональности и стиле. Это инструмент-подсказчик: он предлагает аккордовую
последовательность, которую вы дорабатываете в DAW.
### Параметры
| Поле | Что задаёт |
|------|------------|
| **Модель** | `finetuned` — обучена на вашем корпусе (рекомендуется). `pretrained` — только McGill Billboard, более «общий» звук. |
| **Лад / Тональность** | Мажор или минор и тоника результата (например, `F# major`). Модель генерирует в C/Am и транспонирует в выбранную тональность. |
| **Стиль / Функция** | Метки условия. `H1K0` — авторский стиль. Функция — роль фрагмента (куплет, припев…). |
| **Размер / Subdivision** | Тактовый размер и число позиций на такт. По умолчанию `4/4` и `4`. |
| **Число тактов** | Длина периода. «Авто» — модель сама решает, где закрыть фразу. |
| **Temperature** | Разброс. `1.0` — норма. Выше — смелее и хаотичнее, ниже — предсказуемее. |
| **Top-p** | Нуклеус-сэмплинг. `0.9` — норма. Ниже — консервативнее. |
| **Repetition penalty** | Борется с зацикливанием (I–V–I–V). `0.0` — выкл. Для `pretrained` попробуйте `0.51.0`; для `finetuned` обычно не нужно. |
| **Tonic anchor** | Если префикс пуст — начинать с тоники, чтобы фраза держалась в тональности. |
| **Префикс** | Свои стартовые аккорды через пробел в выбранной тональности, напр. `Cmaj7 . Am7 .`. `.` — держать, `NC` — без аккорда. Если задан, перекрывает tonic anchor. |
| **Seed** | Фиксирует случайность для воспроизводимости. Очистите поле для случайного результата. |
| **Tempo** | Темп MIDI-файла (BPM). На сами аккорды не влияет. |
### Результат
- **Сетка аккордов** — текстовый предпросмотр периода.
- **`.chord`** — исходный формат проекта (человекочитаемый).
- **`.mid`** — импортируйте в REAPER перетаскиванием на дорожку.
### Рекомендации для старта
Модель `finetuned`, `temperature = 1.0`, `top-p = 0.9`, tonic anchor включён.
Если получается монотонно или зациклено — поднимите temperature или добавьте
repetition penalty.
"""
# ---------------------------------------------------------------------------
# UI definition
# ---------------------------------------------------------------------------
def build_ui() -> gr.Blocks:
checkpoints = _available_checkpoints()
default_ckpt = "finetuned" if "finetuned" in checkpoints else (
checkpoints[0] if checkpoints else "finetuned"
)
with gr.Blocks(title="hamori — генератор гармонии") as demo:
gr.Markdown(
"# hamori 🎶 — генератор гармонических периодов\n"
"Заполните форму и нажмите **Сгенерировать**. "
"Подробности — в разделе «Инструкция» внизу."
)
with gr.Row():
# ---- Left: form ------------------------------------------------
with gr.Column(scale=1):
checkpoint = gr.Radio(
choices=checkpoints or ["finetuned", "pretrained"],
value=default_ckpt, label="Модель",
)
with gr.Row():
mode = gr.Radio(["major", "minor"], value="major", label="Лад")
key = gr.Dropdown(NOTE_NAMES, value="C", label="Тональность")
with gr.Row():
style = gr.Dropdown(
STYLES, value="H1K0" if "H1K0" in STYLES else STYLES[0],
label="Стиль",
)
function = gr.Dropdown(
FUNCTIONS,
value="chorus" if "chorus" in FUNCTIONS else FUNCTIONS[0],
label="Функция",
)
with gr.Row():
time = gr.Dropdown(
TIMES, value="4/4" if "4/4" in TIMES else TIMES[0],
label="Размер",
)
subdivision = gr.Radio([4, 8], value=4, label="Subdivision")
with gr.Row():
auto_bars = gr.Checkbox(value=False, label="Авто (длина сама)")
n_bars = gr.Slider(4, 16, value=8, step=1, label="Число тактов")
with gr.Accordion("Сэмплирование", open=True):
temperature = gr.Slider(0.1, 2.0, value=1.0, step=0.05,
label="Temperature")
top_p = gr.Slider(0.1, 1.0, value=0.9, step=0.05, label="Top-p")
repetition_penalty = gr.Slider(
0.0, 2.0, value=0.0, step=0.1, label="Repetition penalty",
)
with gr.Accordion("Дополнительно", open=False):
tonic_anchor = gr.Checkbox(value=True, label="Tonic anchor")
prefix_text = gr.Textbox(
label="Префикс (аккорды через пробел)",
placeholder="напр. Cmaj7 . Am7 .",
)
seed = gr.Number(value=42, precision=0,
label="Seed (пусто = случайно)")
tempo = gr.Number(value=90, precision=0, label="Tempo (BPM)")
run = gr.Button("Сгенерировать", variant="primary")
# ---- Right: outputs -------------------------------------------
with gr.Column(scale=1):
status = gr.Markdown()
bars_out = gr.Textbox(label="Сетка аккордов", lines=10,
interactive=False)
chord_file = gr.File(label="Скачать .chord")
midi_file = gr.File(label="Скачать .mid")
with gr.Accordion("Инструкция", open=False):
gr.Markdown(INSTRUCTIONS_RU)
run.click(
fn=generate,
inputs=[
checkpoint, mode, key, style, function, time, subdivision,
auto_bars, n_bars, temperature, top_p, repetition_penalty,
tonic_anchor, prefix_text, seed, tempo,
],
outputs=[status, bars_out, chord_file, midi_file],
)
return demo
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=7860, help="Server port (default: 7860).")
ap.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1).")
ap.add_argument("--share", action="store_true",
help="Create a temporary public Gradio link.")
args = ap.parse_args()
demo = build_ui()
demo.launch(server_name=args.host, server_port=args.port, share=args.share)
if __name__ == "__main__":
main()
+3
View File
@@ -12,5 +12,8 @@ mido==1.3.3
# Visualization
matplotlib==3.10.9
# Web UI (optional — only needed for app.py)
gradio==6.16.0
# Testing
pytest==9.0.3