Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ __pycache__
**/.ruff_cache
.mypy_cache
**/.mypy_cache
**/.cache
**/.feature-cache
.coverage
htmlcov
node_modules
Expand All @@ -41,8 +43,28 @@ storage/covers/*
reports
docs/PROJECT_RESEARCH_BRIEF.md
apps/api/tests/test_hubert_alignment_real.py
apps/api/tests/test_chart_engine_real_data.py
apps/api/tests/test_chart_rhythm_policy.py
dist
build
wandb
runs
checkpoints
*.pt
*.pth
*.ckpt
*.safetensors
*.onnx
*.gguf
*.npy
*.npz
*.pkl
*.joblib
*.sm
*.ssc
*.beatforge.zip
*.rar
*.7z
*.db
*.db-*
*.sqlite
Expand All @@ -53,4 +75,6 @@ build
*.m4a
*.aac
*.ogg
local-data
材料
*.log
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ BEATFORGE_WEB_PORT=5173
BEATFORGE_DATABASE_URL=sqlite:///./storage/beatforge.db
BEATFORGE_STORAGE_DIR=./storage
BEATFORGE_MAX_UPLOAD_BYTES=262144000
# Optional override for a licensed, local-only SPEED reference corpus:
# BEATFORGE_SPEED_CHARTS_DIR=./local-data/speed-corpus
# Optional private BeatForge package used only by local acceptance tests:
# BEATFORGE_REAL_CHART_PACKAGE=./local-data/reference-song.beatforge.zip
BEATFORGE_DEMUCS_MODEL=htdemucs
BEATFORGE_DEMUCS_DEVICE=auto
BEATFORGE_QWEN_ASR_MODEL=./storage/models/Qwen3-ASR-1.7B
Expand Down
28 changes: 28 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,32 @@ __pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
**/.cache/
**/.feature-cache/
.coverage
htmlcov/
dist/
build/
wandb/
runs/
checkpoints/

# Local model, dataset and export artifacts.
*.pt
*.pth
*.ckpt
*.safetensors
*.onnx
*.gguf
*.npy
*.npz
*.pkl
*.joblib
*.sm
*.ssc
*.beatforge.zip
*.rar
*.7z

# JavaScript dependencies, caches and builds
node_modules/
Expand Down Expand Up @@ -56,6 +78,10 @@ storage/covers/*
*.aac
*.ogg

# Local copyrighted reference corpora used by AI Chart Engine training/tests.
local-data/
材料/

# Public reports are limited to deterministic synthetic-demo measurements.
reports/*
!reports/demo-evaluation.json
Expand All @@ -64,6 +90,8 @@ reports/*
# Local-only acceptance material derived from private projects.
docs/PROJECT_RESEARCH_BRIEF.md
apps/api/tests/test_hubert_alignment_real.py
apps/api/tests/test_chart_engine_real_data.py
apps/api/tests/test_chart_rhythm_policy.py

# Runtime files
*.db
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ DAW 风格界面中。
- Canvas 时间轴缩放、定位、拖动、多选、锁定、吸附、Undo/Redo 与自动保存。
- 区分真实声学位置 `acousticSample` 与音游参考位置 `chartSample`,BPM 不覆盖声学证据。
- 一键导出含参考音频的 BeatForge 制谱包,兼容 JSON、CSV API。
- 本地 AI Chart Engine:读取用户提供且有权使用的 SPEED 参考语料,以 1–15 难度生成、优化、验证并导出五轨 SM。

## AI Chart Engine

`/chart-engine` 提供本地五轨参考谱面库和音频同步预览;项目中的“AI 制谱”工作区把当前歌曲的
BeatForge 声学候选转换为五轨谱面,支持独立转圈开关、可玩性报告和 SM 导出。生成逻辑与
可选 Transformer 均完全本地运行,checkpoint 不存在时使用从本地授权语料统计得到的确定性规则。

```bash
.venv/bin/python scripts/chart_engine.py inventory
.venv/bin/python scripts/chart_engine.py build-dataset \
--mode pump-single --analysis-mode balanced --analyze-missing
.venv/bin/python scripts/train_chart_model.py \
--epochs 12 --batch-size 8 --sequence-length 512 --device auto
```

本地语料约定、数据集契约、训练参数、接口和存储结构见
[AI Chart Engine 文档](docs/AI_CHART_ENGINE.md)。

## 导出数据包

Expand Down Expand Up @@ -164,6 +182,7 @@ storage/
├── vocal-alignment/ 模型子进程临时目录
├── waveform/ 多级波形缓存
├── analyses/ 分析快照
├── chart-engine/ 真实训练数据、模型 checkpoint 与生成谱面
└── beatforge.db SQLite 项目与编辑数据
```

Expand Down
17 changes: 17 additions & 0 deletions apps/api/beatforge_api/chart_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Local five-panel chart generation, validation, import, and export."""

from .generator import generate_chart
from .library import ReferenceLibrary
from .sm import export_sm, parse_sm
from .statistics import chart_statistics, corpus_statistics
from .validator import validate_chart

__all__ = [
"ReferenceLibrary",
"chart_statistics",
"corpus_statistics",
"export_sm",
"generate_chart",
"parse_sm",
"validate_chart",
]
228 changes: 228 additions & 0 deletions apps/api/beatforge_api/chart_engine/dataset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
from __future__ import annotations

import hashlib
import json
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal

from ..audio.io import AudioDecodeError
from ..audio.pipeline import analyze_audio
from ..media import prepare_analysis_source
from .library import ReferenceAsset, ReferenceLibrary
from .statistics import corpus_statistics

AnalysisMode = Literal["recall", "balanced", "clean", "accurate"]


@dataclass(slots=True)
class DatasetBuildReport:
source_chart_count: int
completed: int = 0
skipped: int = 0
failed: int = 0
analyzed_audio_count: int = 0
reused_analysis_count: int = 0
samples: list[str] = field(default_factory=list)
errors: list[dict[str, str]] = field(default_factory=list)

def to_dict(self) -> dict[str, Any]:
return {
"sourceChartCount": self.source_chart_count,
"completed": self.completed,
"skipped": self.skipped,
"failed": self.failed,
"analyzedAudioCount": self.analyzed_audio_count,
"reusedAnalysisCount": self.reused_analysis_count,
"samples": self.samples,
"errors": self.errors,
}


def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def _write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)


def _materialize_audio(source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() or target.is_symlink():
target.unlink()
try:
os.link(source, target)
except OSError:
try:
target.symlink_to(source)
except OSError:
shutil.copy2(source, target)


def _split_for_hash(audio_hash: str) -> str:
bucket = int(audio_hash[:8], 16) % 100
if bucket < 80:
return "train"
if bucket < 90:
return "validation"
return "test"


def _analysis_payload(asset: ReferenceAsset, mode: AnalysisMode) -> dict[str, Any]:
decode_backend = "libsndfile"
try:
result = analyze_audio(asset.audio_path, mode=mode, sensitivity=0.5)
except AudioDecodeError:
# Some corpus MP3s expose valid metadata but fail during a complete
# libsndfile read. Decode those exact source bytes through the same local
# ffmpeg recovery path used by uploaded BeatForge tracks.
with tempfile.TemporaryDirectory(prefix="beatforge-chart-decode-") as directory:
decoded, _ = prepare_analysis_source(
asset.audio_path, Path(directory), force_ffmpeg=True
)
result = analyze_audio(decoded, mode=mode, sensitivity=0.5)
decode_backend = "ffmpeg_pcm_f32le"
payload = result.to_dict()
payload["schema_version"] = "beatforge.analysis.v1"
payload["source_audio"] = asset.audio_path.name
payload["source_decode_backend"] = decode_backend
return payload


def build_dataset(
library: ReferenceLibrary,
output_dir: str | Path,
*,
mode: Literal["pump-single", "pump-double"] = "pump-single",
analyze_missing: bool = False,
analysis_mode: AnalysisMode = "balanced",
limit: int | None = None,
only_ids: set[str] | None = None,
) -> DatasetBuildReport:
"""Build only complete real `(audio, BeatForge, chart)` training triples.

When analysis is absent, no placeholder is written. Callers must explicitly
opt into running the production local analyzer with ``analyze_missing``.
"""

output = Path(output_dir).expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
cache_dir = output / ".feature-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
assets = [asset for asset in library.assets() if library.chart(asset.id).mode == mode]
if only_ids is not None:
assets = [asset for asset in assets if asset.id in only_ids]
if limit is not None:
assets = assets[: max(0, limit)]
report = DatasetBuildReport(source_chart_count=len(assets))
built_charts = []
manifest_samples: list[dict[str, Any]] = []
for asset in assets:
chart = library.chart(asset.id)
audio_hash = _sha256(asset.audio_path)
feature_path = cache_dir / f"{audio_hash}.{analysis_mode}.json"
try:
if feature_path.is_file():
analysis = json.loads(feature_path.read_text(encoding="utf-8"))
# Caches created before decode provenance was added came only
# from successful direct libsndfile analyses.
analysis.setdefault("source_decode_backend", "libsndfile")
report.reused_analysis_count += 1
elif analyze_missing:
analysis = _analysis_payload(asset, analysis_mode)
_write_json(feature_path, analysis)
report.analyzed_audio_count += 1
else:
report.skipped += 1
report.errors.append(
{
"chartId": asset.id,
"code": "BEATFORGE_ANALYSIS_MISSING",
"message": (
"No real BeatForge analysis exists for this SPEED audio. "
"Re-run with analyze_missing enabled."
),
}
)
continue
sample_dir = output / asset.id
sample_dir.mkdir(parents=True, exist_ok=True)
_materialize_audio(asset.audio_path, sample_dir / "audio.mp3")
_write_json(
sample_dir / "beatforge.json",
{
"schemaVersion": "beatforge.chart-training-features.v1",
"audioSha256": audio_hash,
"analysisMode": analysis_mode,
"analysis": analysis,
},
)
_write_json(
sample_dir / "chart.json",
chart.model_dump(by_alias=True, mode="json"),
)
metadata = {
"schemaVersion": "beatforge.chart-training-sample.v1",
"songId": asset.id,
"sourceGroup": asset.group,
"title": chart.title,
"mode": chart.mode,
"difficulty": chart.meter,
"chartBpm": chart.bpm,
"chartOffsetSec": chart.offset_sec,
"durationSec": chart.duration_sec,
"analysisBpm": analysis.get("bpm"),
"analysisBpmConfidence": analysis.get("bpm_confidence"),
"audioSha256": audio_hash,
"chartSha256": _sha256(asset.chart_path),
"split": _split_for_hash(audio_hash),
"audioFile": "audio.mp3",
"beatforgeFile": "beatforge.json",
"chartFile": "chart.json",
"realData": True,
}
_write_json(sample_dir / "metadata.json", metadata)
manifest_samples.append(metadata)
built_charts.append(chart)
report.samples.append(asset.id)
report.completed += 1
except Exception as exc: # each real song should leave an auditable failure
report.failed += 1
report.errors.append(
{"chartId": asset.id, "code": type(exc).__name__, "message": str(exc)}
)
if built_charts:
statistics = corpus_statistics(built_charts)
_write_json(
output / "chart_statistics.json",
statistics.model_dump(by_alias=True, mode="json"),
)
_write_json(
output / "manifest.json",
{
"schemaVersion": "beatforge.chart-training-dataset.v1",
"realDataOnly": True,
"mode": mode,
"analysisMode": analysis_mode,
"sampleCount": len(manifest_samples),
"uniqueAudioCount": len({sample["audioSha256"] for sample in manifest_samples}),
"splits": {
split: sum(sample["split"] == split for sample in manifest_samples)
for split in ("train", "validation", "test")
},
"samples": manifest_samples,
},
)
_write_json(output / "build_report.json", report.to_dict())
return report
Loading
Loading