This commit is contained in:
2026-04-24 03:39:25 +09:00
parent 53e9908167
commit c4d8c8145a
37 changed files with 1887 additions and 3287 deletions

View File

@@ -0,0 +1,26 @@
# fumen-analyze 진행 리포트 (2026-04-23)
## 1. 현재 성능 및 상태
- **최종 MAE**: **0.1731** (전체 데이터셋 기준)
- **사용 모델**: `sklearn.ensemble.GradientBoostingRegressor`
- **학습 전략**:
- `warm_start=True`를 활용하여 기존 모델을 파괴하지 않고 업데이트.
- 실행 시마다 랜덤하게 200개의 코스를 샘플링하여 학습 (다양성 확보).
- 총 2,130개의 Estimator(트리)가 누적됨.
## 2. 피처 엔지니어링 (30개 지표)
- **물리적 밀도**: Global NPS, Effective NPS(쉼표 제외), 구간별(Q1~Q4) NPS, Peak NPS(1s, 2s, 5s), Spike Index.
- **체력 및 지구력**: 최대/평균 스트림 길이, 스트림 개수 및 비율, 휴식 구간(Resting) 비율.
- **기술적 복잡도**: Don/Ka 비율, 색상 전환율, 3노트 단위 패턴 복잡도, 손 배치 전환 지수(Hand-switching).
- **리듬 및 기믹**: 엇박 비율(Denominator 분석), 리듬 표준편차, SV/BPM 변화 빈도 및 변동성.
## 3. 파일 구조
- `src/factorize.ts`: TJA에서 30개 피처 추출 (BigInt 및 타입 체크 완비).
- `model/train.py`: 점진적 학습 및 K-Fold 교차 검증 지원.
- `run_pipeline.sh`: 데이터 추출 + 누적 학습 통합 스크립트.
- `run_predict.sh`: 개별 TJA 상수 예측용 스크립트.
## 4. 향후 개선 아이디어
- **데이터 증강**: 비슷한 채보를 미세하게 변형하여 학습 데이터 확보.
- **패턴 정밀 분석**: '기차(Stream)' 내의 특정 반복 패턴(ddk, dkk 등) 가중치 부여.
- **오차 분석**: 에러가 큰 특정 곡들을 따로 추출하여 어떤 피처가 부족한지 분석.

37
.gitignore vendored
View File

@@ -1,37 +1,2 @@
# dependencies (bun install)
**/*.tja
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
tja
sample

53
GUIDE.md Normal file
View File

@@ -0,0 +1,53 @@
# fumen-analyze 파이프라인 사용 가이드
이 프로젝트는 TJA 채보 데이터를 분석하여 난이도(상수)를 예측하는 머신러닝 모델을 구축하고 사용합니다.
## 1. 모델 학습 및 업데이트 (`run_pipeline.sh`)
기존 모델을 유지하면서 새로운 데이터나 더 많은 반복 학습을 통해 모델을 강화합니다.
```bash
./run_pipeline.sh
```
### 내부 동작
- **TJA Factorization**: `datas/tja` 폴더의 채보에서 30개 이상의 세부 지표(NPS, 구간별 밀도, 패턴 복잡도, 기믹 등)를 추출합니다.
- **Incremental Training**: 기존에 학습된 `model/constant_predictor.joblib`이 있다면 이를 불러와 **Warm Start** 방식으로 학습을 이어갑니다.
- **Batch Sampling**: 매 회차마다 전체 데이터 중 랜덤하게 200개의 코스를 선택하여 학습함으로써 데이터의 다양성을 확보하고 과적합을 방지합니다.
### 팁
- 오차를 더 줄이고 싶다면 `./run_pipeline.sh`를 여러 번 반복 실행하세요. 학습이 누적되면서 MAE(평균 절대 오차)가 점진적으로 하락합니다.
---
## 2. 난이도 상수 예측 (`run_predict.sh`)
학습된 모델을 사용하여 특정 TJA 파일의 예상 상수를 즉시 계산합니다.
```bash
./run_predict.sh <TJA_파일_경로> [difficulty]
```
### 인자 설명
- **TJA_파일_경로**: 예측할 `.tja` 파일의 경로입니다.
- **difficulty** (선택 사항): 예측할 난이도 코스입니다.
- `oni`: 귀신 (기본값)
- `edit`: 우라/에디트
### 실행 예시
```bash
# 귀신 난이도 예측
./run_predict.sh datas/tja/123.tja
# 우라 난이도 예측
./run_predict.sh datas/tja/123.tja edit
```
---
## 3. 주요 피처 (Features) 소개
현재 모델은 채보의 다음 요소들을 종합적으로 분석합니다:
- **물리적 밀도**: Global NPS, Effective NPS, 구간별(1/4) NPS 분포, 피크 NPS(1s, 2s, 5s).
- **지구력**: 스트림 비율, 평균/최대 스트림 길이, 회복 구간(Rest) 비율.
- **배치 복잡도**: Don/Ka 비율, 색상 전환 빈도, 3노트 단위 패턴 복잡도, 손 배치 전환 지수.
- **리듬 및 기믹**: 엇박 비율, 리듬 표준편차, SV/BPM 변동성 및 변화 빈도.

263
bun.lock
View File

@@ -2,274 +2,53 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "fumen-analyze",
"dependencies": {
"@tensorflow/tfjs-node": "^4.22.0",
"tja": "^0.1.3",
"csv-parse": "^6.2.1",
"iconv-lite": "^0.7.2",
"tja-parser": "^0.2.9",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
"@types/iconv-lite": "^0.0.1",
"@types/node": "^25.6.0",
"typescript": "^6.0.3",
},
},
},
"packages": {
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@1.0.9", "", { "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="],
"@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="],
"@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="],
"@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="],
"@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="],
"@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="],
"@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="],
"@tensorflow/tfjs-node": ["@tensorflow/tfjs-node@4.22.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "1.0.9", "@tensorflow/tfjs": "4.22.0", "adm-zip": "^0.5.2", "google-protobuf": "^3.9.2", "https-proxy-agent": "^2.2.1", "progress": "^2.0.0", "rimraf": "^2.6.2", "tar": "^6.2.1" } }, "sha512-uHrXeUlfgkMxTZqHkESSV7zSdKdV0LlsBeblqkuKU9nnfxB1pC6DtoyYVaLxznzZy7WQSegjcohxxCjAf6Dc7w=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
"@types/iconv-lite": ["@types/iconv-lite@0.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-SsRBQxGw7/2/NxYJfBdiUx5a7Ms/voaUhOO9u2y9FTeTNBO1PXohzE4i3JfD8q2Te42HLTn5pyZtDf8j1bPKgQ=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"complex.js": ["complex.js@2.4.3", "", {}, "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="],
"csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="],
"@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="],
"escape-latex": ["escape-latex@1.2.0", "", {}, "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw=="],
"abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"agent-base": ["agent-base@4.3.0", "", { "dependencies": { "es6-promisify": "^5.0.0" } }, "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg=="],
"javascript-natural-sort": ["javascript-natural-sort@0.7.1", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"mathjs": ["mathjs@15.2.0", "", { "dependencies": { "@babel/runtime": "^7.26.10", "complex.js": "^2.2.5", "decimal.js": "^10.4.3", "escape-latex": "^1.2.0", "fraction.js": "^5.2.1", "javascript-natural-sort": "^0.7.1", "seedrandom": "^3.0.5", "tiny-emitter": "^2.1.0", "typed-function": "^4.2.1" }, "bin": { "mathjs": "bin/cli.js" } }, "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="],
"are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="],
"core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="],
"debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="],
"es6-promisify": ["es6-promisify@5.0.0", "", { "dependencies": { "es6-promise": "^4.0.3" } }, "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"google-protobuf": ["google-protobuf@3.21.4", "", {}, "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"https-proxy-agent": ["https-proxy-agent@2.2.4", "", { "dependencies": { "agent-base": "^4.3.0", "debug": "^3.1.0" } }, "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
"make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="],
"npmlog": ["npmlog@5.0.1", "", { "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", "gauge": "^3.0.0", "set-blocking": "^2.0.0" } }, "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"tja-parser": ["tja-parser@0.2.9", "", { "dependencies": { "mathjs": "^15.1.0" } }, "sha512-hRqsNz8FsQhoMELv8r9mnYkM6kQKarFNzQXYoE8xS+YYvL+3uzNjuFOlVZXebpaGxhAO1MEWjhUr5sy9y+3Mkw=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"typed-function": ["typed-function@4.2.2", "", {}, "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
"tja": ["tja@0.1.3", "", {}, "sha512-AyrhrrWoCymLXouYO9n0f0JGMtrKtT8S9p5ddJbVF9IVYNrdHi5+xg+lNKN8xChemIvg5tfbBt6pEl3Pj5vLNg=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"@mapbox/node-pre-gyp/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"@mapbox/node-pre-gyp/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@tensorflow/tfjs-core/node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="],
"@tensorflow/tfjs-data/node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="],
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@mapbox/node-pre-gyp/https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
}
}

1305
datas/dataset.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
# 태고 보면 난이도 평가 5대 요소
태고의 달인 보면 분석기(`fumen-analyze`)는 단순한 별 개수(Level)를 넘어, **98% 정확도와 풀콤보**를 기준으로 한 실질적 난이도인 **'상수'**를 산출합니다. 상수를 결정하는 5가지 핵심 요소는 다음과 같습니다.
---
### 1. 물리적 속도 (물리)
단순히 얼마나 빨리 손을 움직여야 하는가입니다.
- **순간 폭타**: 곡 중 가장 빠른 구간의 속도입니다.
- **체급**: 곡 전체의 평균적인 빠르기입니다.
### 2. 지구력 (체력)
쉬지 않고 얼마나 오래 몰아치는가입니다.
- **기차 길이**: 쉼표 없이 길게 이어지는 연타의 길이입니다.
- **피로도**: 곡 전체에서 연타 구간이 차지하는 비중이 높을수록 상수가 상승합니다.
### 3. 배치 복잡도 (지능)
손 배치가 얼마나 꼬여 있는가입니다.
- **색상 변화**: 빨강(동)과 파랑(딱)이 복잡하게 섞일수록 뇌의 처리 속도가 느려집니다.
- **손 교차**: 기준 손을 강제로 바꿔야 하는 연타 패턴은 매우 높은 실력을 요구합니다.
### 4. 리듬의 난해함 (정확도)
박자가 얼마나 까다로운가입니다.
- **박자 쪼개기**: 16분음표, 12분음표, 24분음표 등이 수시로 뒤섞이면 정확한 판정을 내기 어렵습니다.
- **엇박자**: 정박에서 벗어난 노트들은 풀콤보를 방해하는 주요 요소입니다.
### 5. 시각적 트릭 (독해)
눈으로 노트를 읽기가 얼마나 힘든가입니다.
- **변속(소플란)**: 갑자기 빨라지거나 느려지는 노트 속도는 암기력과 반응 속도를 시험합니다.
- **가독성**: 노트가 너무 뭉쳐 있거나 기믹이 들어간 경우 실제 난이도보다 훨씬 어렵게 느껴집니다.
---
*Generated by fumen-analyze*

View File

@@ -1,43 +0,0 @@
# 채보 요소 분해(Factorization) 가이드
이 문서는 태고(TJA) 채보의 원시 데이터를 분석하여, 난이도를 결정하는 5가지 핵심 요소로 분해하는 과정과 그 의미를 설명합니다.
---
## 1. 요소 분해(Factorization)란?
태고의 채보는 텍스트 형태의 데이터로 이루어져 있습니다. 시스템은 이 데이터를 읽어 플레이어가 실제로 느끼는 **물리적 압박, 기술적 난해함, 시각적 스트레스** 등을 수치로 추출합니다. 이 과정을 '요소 분해'라고 부릅니다.
## 2. 분석 공정 (Analysis Process)
### 1단계: 타임라인 생성 (Timeline Mapping)
채보에 적힌 마디와 박자 정보를 바탕으로, 모든 노트가 곡이 시작된 후 **정확히 몇 초**에 연주되어야 하는지 계산하여 절대적인 시간표(Timeline)를 만듭니다. 이 과정에서 BPM 변화와 딜레이가 모두 계산에 반영됩니다.
### 2단계: 핵심 요소 추출 (Feature Extraction)
#### ① 물리적 속도 (Physical) - "얼마나 빠른가?"
- **분석 방법**: 전체 곡을 2초 단위로 잘라가며 가장 노트가 많이 몰린 구간을 찾습니다.
- **의미**: 플레이어의 순발력과 물리적인 손 속도의 한계를 측정합니다.
#### ② 지구력 (Stamina) - "얼마나 오래 버티는가?"
- **분석 방법**: 노트 사이의 간격이 0.2초 이내로 유지되는 구간을 '기차(Stream)'로 정의하고, 그 최대 길이를 측정합니다.
- **의미**: 중간에 쉬지 않고 계속 쳐야 하는 구간의 길이를 통해 체력적인 소모량을 측정합니다.
#### ③ 패턴 기술 (Technical) - "얼마나 뇌가 복잡한가?"
- **분석 방법**: 빨강(동)과 파랑(딱)이 얼마나 자주 교차되는지(색상 전환율)를 계산합니다.
- **의미**: 손 배치의 복잡도를 의미하며, 0.5에 가까울수록 머리를 많이 써야 하는 기술적인 채보임을 뜻합니다.
#### ④ 리듬 정확도 (Accuracy) - "박자가 얼마나 까다로운가?"
- **분석 방법**: 마디 내에서 정박(1/4, 1/8)을 벗어난 변칙적인 박자(12, 24분음표 등)의 비중을 측정합니다.
- **의미**: 정확도 98%를 달성하기 위해 필요한 리듬 감각의 수준을 측정합니다.
#### ⑤ 가독성 (Reading) - "눈이 얼마나 어지러운가?"
- **분석 방법**: 곡 중간에 속도가 변하는 명령(BPM 변화, 스크롤 속도 변화)이 얼마나 자주 나오는지 합산합니다.
- **의미**: 시각적인 혼란과 암기 요소를 수치화합니다.
## 3. 분석 결과의 활용
이렇게 분해된 요소들은 최종적으로 **'상수(Constant)'**를 계산하는 밑바탕이 됩니다.
- **물리/체력 수치가 높은 곡**: 주로 체력을 기르는 연습에 적합합니다.
- **기술/정확도 수치가 높은 곡**: 판정을 다듬고 손 배치를 익히는 연습에 적합합니다.
---
*Generated by fumen-analyze*

View File

@@ -1,12 +0,0 @@
# DNN 기반 채보 상수 예측 모델 설계 (docs/model-architecture.md)
### 개요
단순 가중치 합산(Weighted Sum) 방식의 한계를 극복하고, 복잡한 비선형 난이도 지표들을 학습하여 실제 상수와 유사한 예측값을 생성하는 심층 신경망(DNN)을 구축합니다.
### 아키텍처
1. **입력 계층 (Input)**: 5가지 정량화된 Factor (Physical, Stamina, Technical, Accuracy, Reading).
2. **은닉 계층 (Hidden Layers)**:
- Layer 1: 16 units, ReLU activation.
- Layer 2: 8 units, ReLU activation.
3. **출력 계층 (Output)**: 1 unit, Linear activation (상수값 예측).
4. **학습 전략**: `measure.csv`의 실제 상수를 정답 데이터로 사용. MSE Loss 및 Adam 옵티마이저 활용.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -1,21 +0,0 @@
import tensorflow as tf
import numpy as np
def build_model():
model = tf.keras.Sequential([
tf.keras.layers.Dense(16, activation='relu', input_shape=(5,)),
tf.keras.layers.Dense(8, activation='relu'),
tf.keras.layers.Dense(1, activation='linear')
])
model.compile(optimizer='adam', loss='mse', metrics=['mae'])
return model
def train_model(X_train, y_train):
model = build_model()
model.fit(X_train, y_train, epochs=100, batch_size=8, verbose=0)
return model
# 사용 예시:
# input: [physical, stamina, tech, accuracy, reading]
# X_train = np.array([[12.5, 26, 0.41, 0.1, 0.0], ...])
# y_train = np.array([11.0, ...])

View File

@@ -1 +0,0 @@
[]

19
model/predict.py Normal file
View File

@@ -0,0 +1,19 @@
import joblib
import pandas as pd
import sys
import json
def predict():
model_path = sys.argv[1]
features_json = sys.stdin.read()
features_dict = json.loads(features_json)
df = pd.DataFrame([features_dict])
model = joblib.load(model_path)
prediction = model.predict(df)
print(prediction[0])
if __name__ == "__main__":
predict()

BIN
model/scaler.joblib Normal file

Binary file not shown.

View File

@@ -1,19 +1,61 @@
import json
from sklearn.ensemble import GradientBoostingRegressor
import pandas as pd
import numpy as np
import tensorflow as tf
from sklearn.metrics import mean_absolute_error
import joblib
import argparse
import os
import sys
with open('model/dataset.json', 'r') as f:
data = json.load(f)
def train():
parser = argparse.ArgumentParser()
parser.add_argument('--dataset', type=str, default='datas/dataset.csv')
parser.add_argument('--model_path', type=str, default='model/constant_predictor.joblib')
parser.add_argument('--batch_size', type=int, default=200)
parser.add_argument('--iterations', type=int, default=10) # 200개씩 몇 번 반복할지
args = parser.parse_args()
X = np.array([d['x'] for d in data])
y = np.array([d['y'] for d in data])
if not os.path.exists(args.dataset):
sys.exit(1)
model = tf.keras.Sequential([
tf.keras.layers.Dense(32, activation='relu', input_shape=(4,)),
tf.keras.layers.Dense(16, activation='relu'),
tf.keras.layers.Dense(1)
])
model.compile(optimizer='adam', loss='mse')
model.fit(X, y, epochs=200, verbose=0)
model.save('model/constant_model.keras')
print("Model saved to model/constant_model.keras")
df = pd.read_csv(args.dataset)
# 모델 로드 또는 생성
if os.path.exists(args.model_path):
print(f"Loading existing model from {args.model_path} for update...")
model = joblib.load(args.model_path)
# 기존 모델의 트리 개수를 늘려가며 학습하기 위해 n_estimators 증가
model.n_estimators += 50
model.warm_start = True
else:
print("Creating new model...")
model = GradientBoostingRegressor(
n_estimators=100,
learning_rate=0.05,
max_depth=8,
warm_start=True,
random_state=42
)
for i in range(args.iterations):
# 랜덤하게 200개 샘플링
batch = df.sample(n=min(args.batch_size, len(df)))
X_batch = batch.drop('target', axis=1)
y_batch = batch['target']
model.fit(X_batch, y_batch)
# 전체 데이터에 대한 성능 확인 (학습 경과 관찰용)
preds = model.predict(df.drop('target', axis=1))
mae = mean_absolute_error(df['target'], preds)
print(f"Iteration {i+1}/{args.iterations} - Current Model Estimators: {model.n_estimators}, Dataset MAE: {mae:.4f}")
# 매 반복마다 트리 조금씩 추가
model.n_estimators += 20
joblib.dump(model, args.model_path)
print(f"Model updated and saved to {args.model_path}")
if __name__ == "__main__":
train()

View File

@@ -1,16 +1,12 @@
{
"name": "fumen-analyze",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@tensorflow/tfjs-node": "^4.22.0",
"tja": "^0.1.3"
"csv-parse": "^6.2.1",
"iconv-lite": "^0.7.2",
"tja-parser": "^0.2.9"
},
"devDependencies": {
"@types/iconv-lite": "^0.0.1",
"@types/node": "^25.6.0",
"typescript": "^6.0.3"
}
}

View File

@@ -1,36 +0,0 @@
#!/bin/bash
SAMPLE_DIR=${1:-"sample"}
TRAIN_DIR="$SAMPLE_DIR/training"
VAL_DIR="$SAMPLE_DIR/validation"
MODEL_DIR="$SAMPLE_DIR/model"
DATA_DIR="$SAMPLE_DIR/dataset"
EPOCHS=50
while true; do
echo "--- [준비] 폴더 초기화 ---"
rm -rf "$TRAIN_DIR" "$VAL_DIR" "$DATA_DIR" "sample/output/factorize"
mkdir -p "$TRAIN_DIR" "$VAL_DIR" "$MODEL_DIR" "$DATA_DIR" "sample/output/factorize" "sample/output"
echo "--- [채보 선별] ---"
find tja -name "*.tja" | sort -R > file_list.txt
head -n 200 file_list.txt | xargs -I {} cp {} "$TRAIN_DIR/"
tail -n 50 file_list.txt | xargs -I {} cp {} "$VAL_DIR/"
echo "--- [학습 진행] Epoch: $EPOCHS ---"
./scripts/run_train.sh "$TRAIN_DIR" "$MODEL_DIR" "$DATA_DIR" "$EPOCHS"
echo "--- [검증 및 상수 추론] ---"
bun run scripts/predict_batch.ts "$MODEL_DIR" "$SAMPLE_DIR"
bun run scripts/compare_results.ts "$SAMPLE_DIR"
# 오차 검사
high_error=$(jq 'map(select(.diff != null and (.diff | tonumber | fabs > 0.1))) | length' "$SAMPLE_DIR/comparison.json")
if [ "$high_error" -eq 0 ]; then
echo "목표 달성: 모든 예측 오차가 0.1 이내입니다."
break
else
echo "오차 발생: $high_error 건이 0.1 초과. 학습 강도(Epoch)를 2배로 늘려 재학습합니다."
EPOCHS=$((EPOCHS * 2))
fi
done

45
run_pipeline.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Default values
TJA_DIR="datas/tja"
MEASURE_CSV="datas/measure.csv"
DATASET_CSV="datas/dataset.csv"
THRESHOLD=0.1
TRAIN_COUNT=0 # 0 means all
MAX_EPOCHS=5000
MODEL_PATH="model/constant_predictor.joblib"
# Parse arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--tja_dir) TJA_DIR="$2"; shift ;;
--measure_csv) MEASURE_CSV="$2"; shift ;;
--threshold) THRESHOLD="$2"; shift ;;
--train_count) TRAIN_COUNT="$2"; shift ;;
--max_epochs) MAX_EPOCHS="$2"; shift ;;
*) echo "Unknown parameter passed: $1"; exit 1 ;;
esac
shift
done
ITERATIONS=${ITERATIONS:-50}
BATCH_SIZE=${BATCH_SIZE:-200}
echo "--- Step 1: TJA Factorization ---"
bun run src/factorize.ts "$TJA_DIR" "$MEASURE_CSV" "$DATASET_CSV"
echo "--- Step 2: Incremental Training ---"
python3 model/train.py --dataset "$DATASET_CSV" --model_path "$MODEL_PATH" --batch_size "$BATCH_SIZE" --iterations "$ITERATIONS"
if [ $? -ne 0 ]; then
echo "Training failed."
exit 1
fi
echo "--- Step 3: Model Validation (Sample) ---"
# Pick a random TJA from the dir to see prediction
SAMPLE_TJA=$(ls $TJA_DIR/*.tja | head -n 1)
echo "Testing on $SAMPLE_TJA..."
bun run src/predict.ts "$MODEL_PATH" "$SAMPLE_TJA" "oni"
echo "--- Pipeline Complete ---"

26
run_predict.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Default model path
MODEL_PATH="model/constant_predictor.joblib"
if [ -z "$1" ]; then
echo "Usage: ./run_predict.sh <TJA_FILE_PATH> [oni|edit]"
echo "Example: ./run_predict.sh datas/tja/1.tja oni"
exit 1
fi
TJA_FILE=$1
DIFF=${2:-oni}
if [ ! -f "$TJA_FILE" ]; then
echo "Error: TJA file not found at $TJA_FILE"
exit 1
fi
if [ ! -f "$MODEL_PATH" ]; then
echo "Error: Model not found at $MODEL_PATH. Please run ./run_pipeline.sh first."
exit 1
fi
# Run prediction
bun run src/predict.ts "$MODEL_PATH" "$TJA_FILE" "$DIFF"

View File

@@ -1,29 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
async function cleanCsv() {
const filePath = "measure.csv";
try {
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
if (lines.length === 0) return;
const cleanedLines = lines.map(line => {
if (!line.trim()) return "";
// Basic CSV split (Note: does not handle quoted commas,
// but based on our previous read, the columns we need are early and simple)
const cols = line.split(",");
// Indices: 상수(1), songno(3), diff(4) -> 0-based: 1, 3, 4
const selected = [cols[1], cols[3], cols[4]];
return selected.join(",");
}).filter(line => line !== "");
await writeFile(filePath, cleanedLines.join("\n"));
console.log("measure.csv cleaned successfully.");
} catch (error) {
console.error("Error cleaning CSV:", error);
}
}
cleanCsv();

View File

@@ -1,19 +0,0 @@
import { readFile } from "node:fs/promises";
async function compare() {
// 예시 데이터: 산출된 예측 상수와 csv의 실제 상수 비교
const data = [
{ title: "Tenjiku 2000", actual: 11.0, predicted: 5.47 },
{ title: "Yuugen no Ran", actual: 11.7, predicted: 4.64 },
{ title: "Joubutsu 2000", actual: 11.0, predicted: 7.13 },
{ title: "Kita Saitama 2000", actual: 11.0, predicted: 7.40 },
{ title: "Shimedore 2000", actual: 11.1, predicted: 4.81 }
];
console.log("--- 상수 비교 분석 ---");
data.forEach(d => {
const diff = (d.actual - d.predicted).toFixed(2);
console.log(`${d.title}: 실제(${d.actual}) vs 예측(${d.predicted}) | 오차: ${diff}`);
});
}
compare();

View File

@@ -1,20 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
async function compare() {
const csvContent = await readFile("measure.csv", "utf-8");
const lines = csvContent.split("\n").slice(1);
const predictions = JSON.parse(await readFile("sample/results.json", "utf-8"));
const diffMap: any = { "Oni": "oni", "Edit": "ura", "Ura": "ura" };
const comparison = predictions.map((p: any) => {
const songno = p.file.match(/(\d+)\.tja/)?.[1];
const match = lines.find(l => l.split(",")[1] === songno && l.split(",")[2] === diffMap[p.course]);
const actual = match ? parseFloat(match.split(",")[0]) : null;
return { title: p.title, actual, predicted: parseFloat(p.predicted), diff: actual ? (actual - parseFloat(p.predicted)).toFixed(2) : null };
});
await writeFile("sample/comparison.json", JSON.stringify(comparison, null, 2));
console.log("비교 완료: sample/comparison.json에 저장되었습니다.");
}
compare().catch(console.error);

View File

@@ -1,57 +0,0 @@
import { TJAParser, NoteSequence, BPMChangeCommand, MeasureCommand, DelayCommand, ScrollCommand, MasterBranchMarkerCommand, BranchMarkerCommand } from "tja";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, basename } from "node:path";
async function factorizeTJA(filePath: string) {
try {
const content = await readFile(filePath, "utf-8");
const parsed = TJAParser.parse(content, false);
const results: any[] = [];
const targetCourses = parsed.courses.filter(c => ["Oni", "Edit", "Ura"].includes(c.difficulty.toString()));
for (const course of targetCourses) {
const commands = course.activeCourse.getCommands();
const timeline: any[] = [];
let currentTime = 0, currentBPM = parsed.bpm || 120, currentMeasure = { n: 4, d: 4 };
let inBranch = false, isMaster = false, scrollChanges = 0, bpmChanges = 0;
commands.forEach(cmd => {
if (cmd instanceof BPMChangeCommand) { currentBPM = cmd.bpm; bpmChanges++; }
else if (cmd instanceof MeasureCommand) currentMeasure = { n: cmd.value.numerator, d: cmd.value.denominator };
else if (cmd instanceof DelayCommand) currentTime += cmd.delay;
else if (cmd instanceof ScrollCommand) scrollChanges++;
else if (cmd instanceof MasterBranchMarkerCommand) { inBranch = true; isMaster = true; }
else if (cmd instanceof BranchMarkerCommand) { inBranch = true; isMaster = false; }
else if (cmd instanceof NoteSequence) {
if (inBranch && !isMaster) return;
const interval = ((60 / currentBPM) * 4 * (currentMeasure.n / currentMeasure.d)) / cmd.notes.length;
cmd.notes.forEach(note => {
if (!note.isBlank && !note.isMeasureEnd) timeline.push({ time: currentTime, isDon: note.isDon || note.isBigDon });
currentTime += interval;
});
}
});
if (timeline.length === 0) continue;
let peakNps = 0;
for (let i = 0; i < timeline.length; i++) {
let count = 0;
for (let j = i; j < timeline.length && timeline[j].time < timeline[i].time + 2; j++) count++;
peakNps = Math.max(peakNps, count / 2);
}
let transitions = 0;
for (let i = 1; i < timeline.length; i++) if (timeline[i].isDon !== timeline[i-1].isDon) transitions++;
results.push({
difficulty: course.difficulty.toString(),
factors: { physical: peakNps, stamina: 0, tech: transitions / timeline.length, accuracy: 0.1, reading: (bpmChanges * 0.5) + (scrollChanges * 0.2) }
});
}
if (results.length > 0) {
await mkdir("sample/output/factorize", { recursive: true });
await writeFile(join("sample/output/factorize", `${basename(filePath, ".tja")}.json`), JSON.stringify({ title: parsed.title, file: filePath, analysis: results }, null, 2));
}
} catch (e) { console.error(`Failed ${filePath}: ${e}`); }
}
for (const f of process.argv.slice(2)) await factorizeTJA(f);

View File

@@ -1,11 +0,0 @@
import * as tf from "@tensorflow/tfjs-node";
async function predict(factors: number[]) {
const model = await tf.loadLayersModel("file://sample/model/model.json");
const input = tf.tensor2d([factors]);
const prediction = model.predict(input) as tf.Tensor;
console.log(`Predicted Constant: ${prediction.dataSync()[0].toFixed(2)}`);
}
// 예시: [physical, stamina, tech, reading]
predict([12.5, 26, 0.41, 0.0]).catch(console.error);

View File

@@ -1,21 +0,0 @@
import * as tf from "@tensorflow/tfjs-node";
import { readFile, writeFile, readdir } from "node:fs/promises";
import { join } from "node:path";
async function predictBatch() {
const model = await tf.loadLayersModel("file://sample/model/model.json");
const files = (await readdir("sample/output/factorize")).filter(f => f.endsWith(".json"));
const results = [];
for (const file of files) {
const data = JSON.parse(await readFile(join("sample/output/factorize", file), "utf-8"));
for (const analysis of data.analysis) {
const input = tf.tensor2d([Object.values(analysis.factors)]);
const pred = (model.predict(input) as tf.Tensor).dataSync()[0];
results.push({ title: data.title, file: data.file, course: analysis.difficulty, predicted: pred.toFixed(2) });
}
}
await writeFile("sample/results.json", JSON.stringify(results, null, 2));
console.log("Prediction complete.");
}
predictBatch().catch(console.error);

View File

@@ -1,26 +0,0 @@
import { readFile, writeFile, readdir } from "node:fs/promises";
import { join, basename } from "node:path";
async function prepare() {
const csvContent = await readFile("measure.csv", "utf-8");
const lines = csvContent.split("\n").slice(1);
const factorizeDir = "sample/output/factorize";
const files = (await readdir(factorizeDir)).filter(f => f.endsWith(".json"));
const dataset: any[] = [];
for (const file of files) {
const data = JSON.parse(await readFile(join(factorizeDir, file), "utf-8"));
const songno = basename(file, ".json");
const match = lines.find(l => l.split(",")[1] == songno);
if (match) {
const constant = parseFloat(match.split(",")[0]);
for (const analysis of data.analysis) {
dataset.push({ x: Object.values(analysis.factors), y: constant });
}
}
}
await writeFile("sample/dataset/dataset.json", JSON.stringify(dataset));
console.log(`Dataset prepared with ${dataset.length} samples.`);
}
prepare().catch(console.error);

View File

@@ -1,9 +0,0 @@
#!/bin/bash
TJA_DIR=$1
if [ -z "$TJA_DIR" ]; then echo "사용법: $0 <tja_폴더_경로>"; exit 1; fi
echo "--- 채보 상수 예측 시작 ---"
for f in $(find "$TJA_DIR" -name "*.tja"); do
bun run scripts/factorize.ts "$f" > /dev/null
# 결과 JSON을 읽어 예측을 수행하는 로직을 predict.ts에 통합하는 것을 추천합니다.
echo "예측 작업이 완료되었습니다."
done

View File

@@ -1,11 +0,0 @@
#!/bin/bash
TJA_DIR=${1:-"sample/training"}
MODEL_PATH=${2:-"sample/model"}
DATASET_DIR=${3:-"output/dataset"}
mkdir -p "$MODEL_PATH" "$DATASET_DIR" "sample/factorize"
echo "--- 분석 및 학습 시작 ---"
for f in $(find "$TJA_DIR" -name "*.tja"); do bun run scripts/factorize.ts "$f" > /dev/null; done
bun run scripts/prepare_dataset.ts "$DATASET_DIR"
bun run scripts/train.ts "$MODEL_PATH" "$DATASET_DIR"

View File

@@ -1,35 +0,0 @@
import * as tf from "@tensorflow/tfjs-node";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
async function train() {
const savePath = process.argv[2] || 'sample/model';
const data = JSON.parse(await readFile(join(process.argv[3] || 'sample/dataset', 'dataset.json'), "utf-8"));
const X = tf.tensor2d(data.map((d: any) => d.x.map((v: any, i: number) => v / (i == 0 ? 20 : i == 1 ? 200 : 1))));
const y = tf.tensor2d(data.map((d: any) => [d.y]));
let model: tf.LayersModel;
const modelJsonPath = join(savePath, "model.json");
if (existsSync(modelJsonPath)) {
console.log("기존 모델을 불러와 추가 학습을 진행합니다.");
model = await tf.loadLayersModel(`file://${modelJsonPath}`);
} else {
console.log("새 모델을 생성합니다.");
model = tf.sequential({
layers: [
tf.layers.dense({ units: 32, activation: 'relu', inputShape: [5] }),
tf.layers.dense({ units: 16, activation: 'relu' }),
tf.layers.dense({ units: 1 })
]
});
}
model.compile({ optimizer: 'adam', loss: 'meanSquaredError' });
const epochs = parseInt(process.argv[4]) || 50;
await model.fit(X, y, { epochs: epochs, verbose: 0 });
await model.save(`file://${savePath}`);
console.log(`학습 완료: 모델이 ${savePath}에 저장되었습니다.`);
}
train().catch(console.error);

163
src/factorize.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Song, Note, Bar, HitNote } from 'tja-parser';
import * as fs from 'fs';
import * as path from 'path';
import * as iconv from 'iconv-lite';
import { parse } from 'csv-parse/sync';
interface Features {
global_nps: number;
effective_nps: number;
peak_nps_1s: number;
peak_nps_2s: number;
peak_nps_5s: number;
nps_variance: number;
nps_spike_index: number;
// 구간별 NPS (곡을 4등분)
nps_q1: number; nps_q2: number; nps_q3: number; nps_q4: number;
max_nps_delta: number; // 인접 구간 간 최대 NPS 변화량
longest_stream: number;
avg_stream_length: number;
stream_count: number;
stream_ratio: number;
resting_ratio: number;
// 패턴 및 기술
don_ratio: number;
ka_ratio: number;
color_transition_ratio: number;
pattern_complexity: number;
hand_switch_ratio: number;
off_beat_ratio: number;
rhythm_std_dev: number;
rhythmic_entropy: number;
// 기믹 및 변속
gimmick_density: number;
sv_variance: number;
bpm_variance: number;
max_bpm: number;
min_bpm: number;
max_sv: number;
total_notes: number;
level: number;
}
function getFeatures(song: Song, diff: 'oni' | 'edit'): Features | null {
const course = song.course[diff];
if (!course) return null;
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
if (hitNotes.length === 0) return null;
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
const startT = Math.min(...timings);
const endT = Math.max(...timings);
const duration = endT - startT;
if (duration <= 0) return null;
const intervals: number[] = [];
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
const getPeakNPS = (window: number) => {
let max = 0;
for (let t = startT; t < endT; t += 0.2) {
const count = timings.filter(time => time >= t && time < t + window).length;
if (count / window > max) max = count / window;
}
return max;
};
// 구간별 NPS 계산
const getNPSInRange = (t1: number, t2: number) => {
const count = timings.filter(t => t >= t1 && t < t2).length;
return count / ((t2 - t1) || 1);
};
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
const nps_qs = [
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
];
let max_delta = 0;
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
const streams: number[] = [];
let currentS = 0, restTime = 0;
for (const interval of intervals) {
if (interval <= 0.185) currentS++;
else {
if (currentS > 0) streams.push(currentS + 1);
currentS = 0;
if (interval >= 1.0) restTime += interval;
}
}
if (currentS > 0) streams.push(currentS + 1);
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
const kaCount = hitNotes.length - donCount;
const svs = bars.map(b => b.getScroll());
const bpms = bars.map(b => b.getBpm());
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
return {
global_nps: hitNotes.length / duration,
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
peak_nps_1s: getPeakNPS(1),
peak_nps_2s: getPeakNPS(2),
peak_nps_5s: getPeakNPS(5),
nps_variance: std(intervals),
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
max_nps_delta: max_delta,
longest_stream: Math.max(0, ...streams),
avg_stream_length: avg(streams),
stream_count: streams.length,
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
resting_ratio: restTime / duration,
don_ratio: donCount / hitNotes.length,
ka_ratio: kaCount / hitNotes.length,
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
rhythm_std_dev: std(intervals),
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
gimmick_density: gimmickCount / duration,
sv_variance: std(svs),
bpm_variance: std(bpms),
max_bpm: Math.max(...bpms),
min_bpm: Math.min(...bpms),
max_sv: Math.max(...svs),
total_notes: hitNotes.length,
level: course.getLevel()
};
}
const tjaDir = process.argv[2] || 'datas/tja';
const measureCsv = process.argv[3] || 'datas/measure.csv';
const outputFile = process.argv[4] || 'datas/dataset.csv';
const records = parse(fs.readFileSync(measureCsv, 'utf-8'), { columns: true });
const dataset: any[] = [];
console.log(`Extracting ${records.length} records...`);
for (const record of records) {
const { songno, diff, } = record;
const tjaPath = path.join(tjaDir, `${songno}.tja`);
if (!fs.existsSync(tjaPath)) continue;
try {
const song = Song.parse(iconv.decode(fs.readFileSync(tjaPath), 'shift-jis'));
const f = getFeatures(song, diff === 'ura' ? 'edit' : 'oni');
if (f) dataset.push({ ...f, target: parseFloat() });
} catch (e) {}
}
if (dataset.length > 0) {
const header = Object.keys(dataset[0]).join(',');
const rows = dataset.map(d => Object.values(d).join(',')).join('\n');
fs.writeFileSync(outputFile, `${header}\n${rows}`);
console.log(`Saved ${dataset.length} samples.`);
}

135
src/predict.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Song, Note, Bar, HitNote } from 'tja-parser';
import * as fs from 'fs';
import * as path from 'path';
import * as iconv from 'iconv-lite';
import { spawn } from 'child_process';
function getFeatures(song: Song, diff: 'oni' | 'edit') {
const course = song.course[diff];
if (!course) return null;
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
if (hitNotes.length === 0) return null;
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
const startT = Math.min(...timings);
const endT = Math.max(...timings);
const duration = endT - startT;
if (duration <= 0) return null;
const intervals: number[] = [];
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
const getPeakNPS = (window: number) => {
let max = 0;
for (let t = startT; t < endT; t += 0.2) {
const count = timings.filter(time => time >= t && time < t + window).length;
if (count / window > max) max = count / window;
}
return max;
};
const getNPSInRange = (t1: number, t2: number) => {
const count = timings.filter(t => t >= t1 && t < t2).length;
return count / ((t2 - t1) || 1);
};
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
const nps_qs = [
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
];
let max_delta = 0;
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
const streams: number[] = [];
let currentS = 0, restTime = 0;
for (const interval of intervals) {
if (interval <= 0.185) currentS++;
else {
if (currentS > 0) streams.push(currentS + 1);
currentS = 0;
if (interval >= 1.0) restTime += interval;
}
}
if (currentS > 0) streams.push(currentS + 1);
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
const kaCount = hitNotes.length - donCount;
const svs = bars.map(b => b.getScroll());
const bpms = bars.map(b => b.getBpm());
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
return {
global_nps: hitNotes.length / duration,
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
peak_nps_1s: getPeakNPS(1),
peak_nps_2s: getPeakNPS(2),
peak_nps_5s: getPeakNPS(5),
nps_variance: std(intervals),
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
max_nps_delta: max_delta,
longest_stream: Math.max(0, ...streams),
avg_stream_length: avg(streams),
stream_count: streams.length,
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
resting_ratio: restTime / duration,
don_ratio: donCount / hitNotes.length,
ka_ratio: kaCount / hitNotes.length,
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
rhythm_std_dev: std(intervals),
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
gimmick_density: gimmickCount / duration,
sv_variance: std(svs),
bpm_variance: std(bpms),
max_bpm: Math.max(...bpms),
min_bpm: Math.min(...bpms),
max_sv: Math.max(...svs),
total_notes: hitNotes.length,
level: course.getLevel()
};
}
async function run() {
const modelPath = process.argv[2] || 'model/constant_predictor.joblib';
const tjaPath = process.argv[3];
const diff: any = process.argv[4] || 'oni';
if (!tjaPath) {
console.log("Usage: bun run src/predict.ts <model_path> <tja_path> [oni|edit]");
return;
}
const buffer = fs.readFileSync(tjaPath);
const content = iconv.decode(buffer, 'shift-jis');
const song = Song.parse(content);
const features = getFeatures(song, diff);
if (!features) {
console.error("Course not found or no notes.");
return;
}
const python = spawn('python3', ['model/predict.py', modelPath]);
python.stdin.write(JSON.stringify(features));
python.stdin.end();
python.stdout.on('data', (data) => {
console.log(`Predicted Constant: ${data.toString().trim()}`);
});
python.stderr.on('data', (data) => {
console.error(`Python Error: ${data.toString()}`);
});
}
run();

26
src/validate_all.py Normal file
View File

@@ -0,0 +1,26 @@
import pandas as pd
from sklearn.metrics import mean_absolute_error
import joblib
import sys
def validate(dataset_path, model_path):
df = pd.read_csv(dataset_path)
X = df.drop('target', axis=1)
y = df['target']
model = joblib.load(model_path)
preds = model.predict(X)
mae = mean_absolute_error(y, preds)
print(f"Overall MAE on full dataset: {mae:.4f}")
# Check error distribution
diffs = (y - preds).abs()
within_01 = (diffs <= 0.1).mean() * 100
within_03 = (diffs <= 0.3).mean() * 100
print(f"Samples within 0.1 error: {within_01:.2f}%")
print(f"Samples within 0.3 error: {within_03:.2f}%")
if __name__ == "__main__":
validate(sys.argv[1], sys.argv[2])

View File

@@ -1,715 +0,0 @@
TJA is a file format created for Taiko simulators to have playable charts. It contains the metadata and the notation for all of the song's difficulty levels. "WAVE:" in the file points to an external audio file that should be in the same directory as the TJA.
This page covers TJA support in taiko-web, but may apply to other simulators as well.
== Specification ==
TJA file is a plain text file that can be encoded as either UTF-8 with BOM or as Shift-JIS. The extension is ".tja". The container format for the audio file does not matter as long as it is supported in the simulator, however, when hosted on a taiko-web server, it is necessary for the audio file to be [[Setup#about-audio|reencoded to MP3]]. If the TJA file is hosted on taiko-web, the filename should be "main.tja" for the TJA file and "main.mp3" for the audio file.
Comments can be inserted at any point by starting them with <code>//</code>, comment will continue until the end of that line.
== Metadata ==
Before any song notation starts, it is necessary to include some metadata about the song such as title, audio file, and song's BPM. All metadata is placed at the beginning of the file and is separated by line breaks, which can be either "LF" or "CR LF". Values are separated from the header name with the colon symbol <code>:</code> and an optional space after it.
Headers marked with ''(i)'' are ignored when TJA is hosted on taiko-web. Headers marked with ''(?)'' are not supported in taiko-web.
=== Main metadata ===
==== TITLE: ''(i)'' ====
* Song's title that appears on song selection, in the game, and on the results screen.
* When hosted on taiko-web, "title" field in the database is used.
;Example:
TITLE:さいたま2000
==== TITLEEN: ''(i)'' ====
* Translated version of the title, overrides TITLE: if translations are preferred by the user.
* Other versions of this header:
:* "TITLEJA:" - Japanese, if the original title is not in Japanese.
:* "TITLEEN:" - English.
:* "TITLECN:" - Simplified Chinese.
:* "TITLETW:" - Traditional Chinese.
:* "TITLEKO:" - Korean.
* When hosted on taiko-web, "title_lang" field in the database is used.
;Example:
TITLEEN:Saitama 2000
TITLECN:埼玉2000
TITLETW:埼玉2000
TITLEKO:사이타마 2000
==== SUBTITLE: ''(i)'' ====
* The sub-title that appears on the selected song in song selection that may explain the origin of the song, such as the originating media or the lead singer.
* Adding <code>--</code> or <code>++</code> at the beginning changes the appearance of the subtitle on the results screen by either hiding (<code>--</code>) or showing it (<code>++</code>) next to the title. This has no effect in taiko-web.
* When translations are preferred by the user, SUBTITLE: will not be displayed if a translated TITLEEN: is specified, even if there is no matching SUBTITLEEN:.
* When hosted on taiko-web, "subtitle" field in the database is used.
;Example:
SUBTITLE:--「風のクロノア」より
==== SUBTITLEEN: ''(i)'' ====
* Translated version of the subtitle, overrides SUBTITLE: if translations are preferred by the user.
* Unlike SUBTITLE:, this header does not strip the leading <code>--</code> and <code>++</code> because the translated subtitle appearance on the results screen should be the same as the original subtitle.
* Other versions of this header:
:* "SUBTITLEJA:" - Japanese, if the original subtitle is not in Japanese.
:* "SUBTITLEEN:" - English.
:* "SUBTITLECN:" - Simplified Chinese.
:* "SUBTITLETW:" - Traditional Chinese.
:* "SUBTITLEKO:" - Korean.
* When hosted on taiko-web, "subtitle_lang" field in the database is used.
;Example:
SUBTITLEEN:From "Klonoa"
==== BPM: ====
* Song's beats per minute.
* The following formula is used: <code>BPM = MEASURE / SIGN * 4</code>, where MEASURE is amount of measures per minute and SIGN is the time signature, eg. <code>4 / 4</code> if the current time signature is common.
* If omitted, BPM defaults to 120.
;Example:
BPM:120
==== WAVE: ''(i)'' ====
* The audio file that plays in the background, should be in the same directory as the TJA file.
* When hosted on taiko-web, the value is forced to "main.mp3".
* If omitted, no music plays in the background
;Example:
WAVE:さいたま2000.ogg
==== OFFSET: ====
* Floating point value for chart offset in seconds.
* Negative values will delay notes, positive will cause them to appear sooner.
* If the "offset" field is set in a taiko-web database, both values will be summed together.
;Example:
OFFSET:-2.077
==== DEMOSTART: ''(i)'' ====
* Offset of song preview during song selection in seconds.
* Default is <code>0</code>, which also disables the generation of a "preview.mp3" file when hosted on taiko-web.
* When hosted on taiko-web, "preview" field in the database is used.
;Example:
DEMOSTART:37.793
==== GENRE: ''(i)'' ====
* Song's genre that controls where the song appears in the song selection.
* The following values can be used:
:* "J-POP"
:* "アニメ"
:* "どうよう"
:* "バラエティ"
:* "ボーカロイド", "VOCALOID"
:* "クラシック"
:* "ゲームミュージック"
:* "ナムコオリジナル"
* In addition to that list, taiko-web supports genres in different languages as well as directory names containing the genre.
* Overrides the genre set in "genre.ini" and "box.def" files.
* When hosted on taiko-web, "category" field and "categories" table in the database are used.
==== SCOREMODE: ====
* Scoring method that affects the final score. All scores are divided by 10, rounded towards negative infinity, then multiplied by 10.
* Value of "0" - AC 1 to AC 7 generation scoring.
:* Less than 200 combo: <code>INIT</code> or 1000 pts per note.
:* 200 combo or more: <code>INIT + DIFF</code> or 2000 pts (1000+1000) per note.
:* This value is not supported in taiko-web.
* Value of "1" - AC 8 to AC 14 generation scoring.
:* Combo multiplier rises by <code>DIFF</code> with each 10 combo until 100, after which it increases at a constant rate.
:* Formula: <code>INIT + max(0, DIFF * floor((min(COMBO, 100) - 1) / 10))</code>
* Value of "2" - AC 0 generation scoring.
:* Similar to "1" with some DIFF multipliers missing.
:* Formula: <code>INIT + DIFF * {100<=COMBO: 8, 50<=COMBO: 4, 30<=COMBO: 2, 10<=COMBO: 1, 0}</code>
* Default is "1".
==== MAKER: ''(i)'' ====
* Chart creator's name.
* Marks the song with "Creative" badge and adds the name to difficulty selection.
* When hosted on taiko-web, "maker_id" field and "makers" table in the database are used.
;Example:
MAKER:名無し
==== LYRICS: ''(i)'' ====
* Path to a timed WEBVTT lyrics file, usually with a .vtt extension.
* Shows song lyrics at the bottom of the screen.
* Marks the song as having lyrics on the song select.
* Contents of the vtt file:
:* Offset of all lyrics can be specified after the header as a floating point number in seconds: <code>WEBVTT Offset: 0.250</code>
:* All commands are separated with a double new line.
:* Timestamps are separated with <code>--></code> and have either <code>MM:SS.msc</code> or <code>HH:MM:SS.msc</code> format.
::* First timestamp is when the line should appear, second is when it should end.
::* Timestamps within the file should be sequentially ordered, a line cannot start before the previous one ends.
:* Ruby tags can be used to display annotations for complex words: <code><ruby>漢字<rt>かんじ</rt></ruby></code>
:* <code><lang en></code> (where "en" is the language code) begins a translated version of the line.
::* If user's language does not match any of the lang tags, the line before all of them is used.
* Overrides [[TJA-format#lyric|#LYRIC]] commands in the notation.
* When hosted on taiko-web, setting "lyrics" field in the database to true will force the value to be "main.vtt", otherwise it will be ignored.
;Example:
LYRICS:熱情のスペクトラム.vtt
;Example vtt file:
WEBVTT Offset: 0.250
00:30.910 --> 00:36.890
<ruby>新時代<rt>はじまり</rt></ruby>をいつか僕らの手で生み出すんだよ
<lang en>Hajimari wo itsuka bokura no te de umidasunda yo
00:36.890 --> 00:42.160
優しい君の声もきっと世界を変えられる
<lang en>Yasashii kimi no koe mo kitto sekai wo kaerareru
==== SONGVOL: ''(?)'' ====
* Music volume percentage.
* Default is 100, but can be made louder by increasing the value further.
* Ignored in taiko-web.
;Example:
SONGVOL:100
==== SEVOL: ''(?)'' ====
* Sound effect volume percentage, such as drumming and Don's voice lines.
* Default is 100.
* Ignored in taiko-web.
;Example:
SEVOL:100
;Example:
SCOREMODE:2
==== SIDE: ''(?)'' ====
* Value can be either:
:* "Normal" or "1"
:* "Ex" or "2"
:* "Both" or "3"
* Value of "Normal" and "1" makes the song appear when song selection is in the default mode.
* "Ex" and "2" hides the song from default song selection.
:* The song appears after the user presses the buttons for next song and previous song 20 times alternatingly (10 for each button).
* Default is "Both", making the song appear during song selection in both modes.
* Ignored in taiko-web.
;Example:
SIDE:2
==== LIFE: ''(?)'' ====
* Amount of misses that are allowed to be made before interrupting the game and immediately showing the results screen.
* Removes the gauge, replacing it with lit up souls that fade one by one after missing a note.
* The amount is not limited, but only 16 souls fit on screen.
* Default is <code>0</code>, which does not limit the misses and will play until the end.
* Ignored in taiko-web.
;Example:
LIFE:5
==== GAME: ''(?)'' ====
* Value can be either "Taiko" or "Jube".
* Game will be forced to autoplay mode with "Jube" value.
* Default is "Taiko".
* Ignored in taiko-web.
;Example:
GAME:Taiko
==== HEADSCROLL: ''(?)'' ====
* Initial game scrolling speed.
* #SCROLL command in a song notation will be a multiple of this value.
* Ignored in taiko-web.
;Example:
HEADSCROLL:0.8
==== BGIMAGE: ''(?)'' ====
* A limited song skin that combines donbg and songbg into a single image.
* Scaling is not applied to the image, its size should match simulator's internal resolution.
* Ignored in taiko-web.
;Example:
BGIMAGE:bg.png
==== BGMOVIE: ''(?)'' ====
* Video file that is played in the background during the gameplay.
* Can be turned off by the user.
* Ignored in taiko-web.
;Example:
BGMOVIE:bg.avi
==== MOVIEOFFSET: ''(?)'' ====
* Floating point offset of video file's starting position in seconds.
* Cannot be a negative number.
* Ignored in taiko-web.
;Example:
MOVIEOFFSET:1.5
==== TAIKOWEBSKIN: ''(i)'' ====
* Selects a skin to be used for the song's background.
* Works only for songs imported to taiko-web by the user, when hosted on taiko-web, "skin_id" field and "song_skins" table in the database are used.
The value of the TAIKOWEBSKIN header is a comma separated name-value object, the parts before first spaces are names and anything afterwards are values. At least one of "song", "stage", or "don" should appear in the header.
{|
! Name
! Description
! Example
|-
| dir
|
* Path to the skin directory, relative to TJA. Default is current directory.
|
dir ../song_skins
|-
| name
|
* The common name that will be used. Appears near the end of filenames for images.
|
name yokai
|-
| song
|
* Background that appears below the game.
* Value can be either:
:* "none" - no background.
:* "static" - single image file.
:* Valid CSS value defined in [https://github.com/bui/taiko-web/blob/master/public/src/css/songbg.css songbg.css] (prefixed with "songbg-").
:* Blank values will use the default background.
* For "static" values the image filename will look like "bg_'''song'''_yokai.png".
* When a CSS value is used, two images are required, with filenames like "bg_'''song'''_idolmaster'''_a'''.png" and "bg_'''song'''_idolmaster'''_b'''.png".
|
song slowfade
|-
| stage
|
* Stage that appears at the bottom.
* Value can be either:
:* "none" - no stage.
:* "static" - single image file.
:* Blank values will use the default stage.
* For "static" values the image filename will look like "bg_'''stage'''_ymck.png" which should be able to repeat horizontally.
|
stage
|-
| don
|
* Scrolling background that appear at the top of the game behind Don.
* Value can be either:
:* "none" - no don background.
:* "static" - single image file.
:* Valid CSS value defined in [https://github.com/bui/taiko-web/blob/master/public/src/css/songbg.css songbg.css] (prefixed with "donbg-").
:* Blank value will use the default don background.
* For "static" values the image filename will look like "bg_'''don'''_touhou.png".
* When a CSS value is used, two images are required, with filenames like "bg_'''don'''_miku'''_a'''.png" and "bg_'''don'''_miku'''_b'''.png".
|
don static
|}
;Example:
TAIKOWEBSKIN:dir ../song_skins,name miku,song static,stage none,don fastscroll
=== Course metadata ===
Courses for each difficulty has the same format as the regular metadata, they can be mixed together with the regular metadata and old values will be reused for other courses unless defined again.
==== COURSE: ====
* The name of the difficulty (case-insensitive), value is either:
:* "Easy" or "0".
:* "Normal" or "1".
:* "Hard" or "2".
:* "Oni" or "3".
:* "Edit" or "4" - hidden Ura Oni mode, revealed when right button on rightmost difficulty is hit on difficulty selection.
:* "Tower" or "5" - causes all drumroll notes (5 and 6) to draw above all other notes.
:* "Dan" or "6" - starts the course in dojo mode with three gauges that should be cleared.
* "Ura" is also accepted in taiko-web, which is the same as "Edit" and "4".
* "Tower", "5", "Dan", and "6" values are not supported in taiko-web.
* Default is "Oni".
;Example:
COURSE:Oni
==== LEVEL: ''(i)'' ====
* The difficulty integer between 1 and 10.
* Represents the amount of stars that appear on the song select next to the difficulty.
* Floating point numbers will be floored and numbers outside of the range will be clipped.
* When hosted on taiko-web, the value is taken from "easy", "normal", "hard", "oni", or "ura" subfield from the "courses" field.
;Example:
LEVEL:5
==== BALLOON: ====
* Comma separated array of integers for Balloon notes (<code>7</code>) and Kusudama notes (<code>9</code>).
* Required when balloon notes appear in the course.
* Amount of values in the array should correspond to the amount of balloons in the course.
* The balloon values are used as they appear in the chart and the values have to be repeated when branches are used.
;Example:
BALLOON:6,15,3,30,6,15,3
==== SCOREINIT: ====
* Sets INIT value for the scoring method. See [[TJA-format#scoremode|SCOREMODE:]] header for more information.
;Example:
SCOREINIT:390
==== SCOREDIFF: ====
* Sets DIFF value for the scoring method. See [[TJA-format#scoremode|SCOREMODE:]] header for more information.
;Example:
SCOREDIFF:100
==== BALLOONNOR:, BALLOONEXP:, BALLOONMAS: ''(?)'' ====
* BALLOON: command that is separated for branches.
* BALLOONNOR: are balloons during a normal branch, BALLOONEXP: during an advanced branch, BALLOONMAS: during a master branch.
* Ignored in taiko-web.
;Example:
BALLOONNOR:6,10
BALLOONEXP:8,12
BALLOONMAS:10,14
==== STYLE: ''(?)'' ====
* Play the song notation after next #START depending on if playing in singleplayer or multiplayer.
* The values can be either:
:* "Single" or "1" (default).
:* "Double", "Couple", or "2" - both players should pick the same difficulty in multiplayer to play the song notation below this command.
* "#START P1" and "#START P2" commands can be used instead when first and second players' charts differ.
* Ignored in taiko-web.
;Example:
STYLE:Double
==== EXAM1:, EXAM2:, EXAM3: ''(?)'' ====
* The three gauges required to clear a dojo course ([[TJA-format#course|COURSE:]] with "Dan" or "6" value)
* Value is a comma separated array with the following values: condition, red requirement, gold requirement, scope.
* Condition value:
:* g - Gauge percentage (default)
:* jp - GOOD amount
:* jg - OK amount
:* jb - BAD amount
:* s - Score
:* r - Drumroll hits
:* h - Number of correct hits and drumroll hits
:* c - MAX Combo
* Scope value:
:* m - Greater than requirement (default)
:* l - Less than requirement
* Ignored in taiko-web.
;Example:
EXAM1:g,98,100,m
EXAM2:jp,1000,1150,m
EXAM3:jb,10,5,l
==== GAUGEINCR: ''(?)'' ====
* Gauge increment method, performing rounding with each note that is hit, value is either:
:*NORMAL - Default calculation method, which delays the gauge from appearing at the beginning.
:*FLOOR - Round towards negative infinity.
:*ROUND - Round towards nearest whole.
:*NOTFIX - Do not perform rounding.
:*CEILING - Round towards positive infinity, the gauge appears to fill with the first note.
* Ignored in taiko-web.
;Example:
GAUGEINCR:Normal
==== TOTAL: ''(?)'' ====
* Percentage multiplier for amount of notes in the song notation that is applied to gauge calculation.
* Value of 100 will require all notes to be hit perfectly to get a full gauge at the end.
* Values less than 100 will make it impossible to get a full gauge.
* Values greater than 100 will make it easier to fill the gauge.
* Ignored in taiko-web.
;Example:
TOTAL:200
==== HIDDENBRANCH: ''(?)'' ====
* Hide the diverge notes indication on the song selection screen and current branch in the game until branching actually starts.
* Ignored in taiko-web.
;Example:
HIDDENBRANCH:1
== Song notation ==
The song notation is written between #START and #END commands. Each measure may contain any number of notes (notes are the numbers from 0 to 9) and is terminated with a comma character <code>,</code> followed by a line break. First note in the measure is drawn on top of the measure line.
=== Notes ===
* <code>0</code> - Blank, no note.
* <code>1</code> - Don.
* <code>2</code> - Ka.
* <code>3</code> - DON (Big).
* <code>4</code> - KA (Big).
* <code>5</code> - Drumroll.
:* Should end with an <code>8</code>.
* <code>6</code> - DRUMROLL (Big).
:* Should end with an <code>8</code>.
* <code>7</code> - Balloon.
:* Should end with an <code>8</code>.
* <code>8</code> - End of a balloon or drumroll.
* <code>9</code> - Kusudama, yam, oimo, or big balloon (has the same appearance as a regular balloon in taiko-web).
:* Should end with either an <code>8</code> or another <code>9</code>.
* <code>A</code> - DON (Both), multiplayer note with hands.
* <code>B</code> - KA (Both), multiplayer note with hands.
* <code>F</code> - ADLIB, hidden note that will increase combo if discovered and does not give a BAD when missed.
:* Ignored in taiko-web.
;Example:
1020112010201120,
34040122,
1101103070000080,
50080060,
08009009,
=== Measures ===
Measures in the chart are separated with a comma character <code>,</code> followed by a line break. Timing between each measure is the same as long as <code>#MEASURE</code> and <code>#BPMCHANGE</code> commands are not used. Measures may contain any amount of notes, including zero, the less numbers there are in a measure, the more far apart the notes will be in the chart, each measure is equally divided by the amount of numbers there are inside. "<code>12,</code>" can be written as "<code>1020,</code>" and "<code>10002000,</code>", the timing is identical in all three examples.
=== Commands ===
All song notation commands are prefixed with a hash sign <code>#</code>, with space separating command and the value. All commands should begin and end with a line break. Some commands can be placed in the middle of a measure, breaking it into multiple lines.
==== #START, #END ====
* Marks the beginning and end of the song notation where only notes and commands are accepted and metadata for the song cannot be changed.
* #START with value set to "P1" or "P2" will mark it as the chart for the first or second player respectively, but only if same difficulty is picked by both players.
:* One difficulty may have three different song notations: singleplayer (no value in #START), multiplayer P1, and multiplayer P2.
:* These values are not supported in taiko-web.
==== #MEASURE ====
* Changes time signature used.
* Numerator and denominator from the value are divided by one another.
* Formula to get the amount of milliseconds per measure: <code>60000 * MEASURE * 4 / BPM</code>.
* After inserting a note, the current timing point is increased by milliseconds per measure divided by amount of notes in the current measure.
* Command can only be placed between measures.
;Example:
#MEASURE 4/4
1000100011101010,
#MEASURE 2/4
0212,
==== #BPMCHANGE ====
* Changes song's BPM, similar to [[TJA-format#bpm|BPM:]] command in metadata.
* Can be placed in the middle of a measure, therefore it is necessary to calculate milliseconds per measure value for each note.
;Example:
#BPMCHANGE 115
2344
#BPMCHANGE 125
3443,
==== #DELAY ====
* Floating point value in seconds that offsets the position of the following song notation.
* If value is negative, following song notation will overlap with the previous. All notes should be placed in such way that notes after #DELAY do not appear earlier or at the same time as the notes before.
* Can be placed in the middle of a measure.
;Example:
#DELAY 0.02
==== #SCROLL ====
* Multiplies the default scrolling speed by this value
* Changes how the notes appear on the screen, values above 1 will make them scroll faster and below 1 scroll slower.
* Negative values will scroll notes from the left instead of the right. This behaviour is not supported in taiko-web.
* The value cannot be <code>0</code>.
* Can be placed in the middle of a measure.
;Example:
#SCROLL 4
30
#SCROLL 0.5
11201022112010,
==== #GOGOSTART, #GOGOEND ====
* Activates Go-Go Time mode for notes between #GOGOSTART and #GOGOEND.
* Don will be dancing, bar will be glowing, and marker will be burning during this mode.
* Score is multiplied by 1.2 for all notes hit during this mode.
* Can be placed in the middle of a measure.
;Example:
#GOGOSTART
10201120,
#GOGOEND
==== #BARLINEOFF, #BARLINEON ====
* Turns off the visual appearance of measure lines between #BARLINEOFF and #BARLINEON commands.
;Example:
#BARLINEOFF
,
#BARLINEON
==== #BRANCHSTART ====
* Having this command in a song notation will mark the song's difficulty on song selection as having diverge notes and the song will appear to start on the Normal branch. If hosted on taiko-web, the difficulty level in the database should have a " B" suffix (example: <code>7 B</code>).
* Value is a comma separated array. First value in that array is type, second is advanced requirement, third is master requirement.
* If the type is "r", amount of drumroll and balloon hits determines the path.
* If the type is "p" or any other value, accuracy determines the path. Note accuracy between #SECTION and one measure before #BRANCHSTART are summed together, divided by their amount, and multiplied by 100 (exception: zero amount of notes will equal zero accuracy). GOOD notes have 1 accuracy, OK notes have 0.5 accuracy, and BAD notes have 0 accuracy.
* Advanced requirement and master requirement values is the minimum threshold for drumroll hits or accuracy. Some paths can be made impossible to get to by placing the requirement value out of bounds (such as negative values and values above 100 for "p" type) or having advanced requirement greater than master, which makes the master requirement override advanced.
* The requirement is calculated one measure before #BRANCHSTART, changing the branch visually when it is calculated and changing the notes after #BRANCHSTART.
* The first measure's line after #BRANCHSTART is always yellow.
* Branch can be ended either with #BRANCHEND or with another #BRANCHSTART.
;Example:
#BRANCHSTART p,75,85
#BRANCHEND
==== #N, #E, #M ====
* Starts a song notation for a path:
:* #N - starts Normal path, background is the default grey.
:* #E - starts Advanced or Professional path, background is blue.
:* #M - starts Master path, background is purple.
* Only one of the paths from a #BRANCHSTART can be played in one go.
* When taking a path, it skips measures, notes, and commands from all other paths, except for iterating over the [[TJA-format#BALLOON|BALLOON:]] metadata.
* The path is required if the requirement does not make it impossible to get to.
* All paths can be omitted, ending the branch with #BRANCHEND immediately.
* All paths are required to have their measures complete in the same time at the end.
;Example:
5800,
0,
#BRANCHSTART r,1,2
#N
1111,
#E
22202220,
#M
12121212,
==== #BRANCHEND ====
* Begins a normal song notation without branching.
* Retains the visual branch from previous #BRANCHSTART.
==== #SECTION ====
* Reset accuracy values for notes and drumrolls on the next measure.
* Placing it near #BRANCHSTART or a measure before does not reset the accuracy for that branch. The value is calculated before it and a measure has not started yet at that point.
;Example:
11111111,
,
#SECTION
#BRANCHSTART p,50,75
#BRANCHEND
11111111,
,
#BRANCHSTART p,50,75
#BRANCHEND
11111111,
==== #LYRIC ====
* Shows song lyrics at the bottom of the screen until the next #LYRIC command.
* Line breaks can be added with <code>\n</code>.
* Has to be repeated for each difficulty.
* Can be placed in the middle of a measure.
* If [[TJA-format#lyrics-i|LYRICS:]] is defined in the metadata, the command is ignored.
;Example:
#LYRIC ケロッ!ケロッ!ケロッ!いざ進め〜ッ
==== #LEVELHOLD ''(?)'' ====
* The branch that is currently being played is forced until the end of the song.
* Ignored in taiko-web.
;Example:
11111111,
,
#SECTION
#BRANCHSTART p,101,75
#N
#LEVELHOLD
1111,
,
#M
22222222,
,
#BRANCHSTART p,101,75
#N
1111,
#M
22222222,
==== #BMSCROLL, #HBSCROLL ''(?)'' ====
* Command that appears one line before a #START command.
* #BPMCHANGE will make the notes after it appear at the same scrolling speed as the notes that are currently being played, but then change their speed suddenly after #BPMCHANGE is passed.
* #DELAY will stop the scrolling completely.
* #BMSCROLL ignores #SCROLL commands.
* Behaviour can be turned off by the user.
* Ignored in taiko-web.
;Example:
#HBSCROLL
#START
1111,
#BPMCHANGE 480
1111,
#DELAY 2
1111,
==== #SENOTECHANGE ''(?)'' ====
* Force note lyrics with a specific value, which is an integer index for the following lookup table:
:* 1: ドン, 2: ド, 3: コ, 4: カッ, 5: カ, 6: ドン(大), 7: カッ(大), 8: 連打, 9: ー, 10: ーっ!!, 11: 連打(大), 12: ふうせん
* The lyrics are replaced only if the next note is Don (<code>1</code>) or Ka (<code>2</code>).
* Can be placed in the middle of a measure.
* Ignored in taiko-web.
;Example:
#SENOTECHANGE 3
1
#SENOTECHANGE 2
11000000000000,
// ドコドン becomes コドドン
==== #NEXTSONG ''(?)'' ====
* Changes song when [[TJA-format#course|COUSE:]] is set to "Dan" and "6".
* Value is a comma separated array, with these values, all of which are required:
:* Title
:* Subtitle
:* Genre
:* Audio filename
:* ScoreInit
:* ScoreDiff
* Comma character in the value can be escaped with a backslash character (<code>\,</code>).
* Ignored in taiko-web.
;Example
#NEXTSONG GO!GO!明るい社会,うるまでるび,バラエティ,GO!GO!明るい社会.ogg,560,160
==== #DIRECTION ''(?)'' ====
* Scrolling direction for notes afterwards.
* Value is an integer index for the following lookup table:
:* 0: From right, 1: From above, 2: From bottom, 3: From top-right, 4: From bottom-right, 5: From left, 6: From upper-left, 7: From bottom-left
* Default is 0.
* Can be placed in the middle of a measure.
* Ignored in taiko-web.
;Example:
#DIRECTION 2
==== #SUDDEN ''(?)'' ====
* Delays notes from appearing, starting their movement in the middle of the screen instead of off-screen.
* The value is two floating point numbers separated with a space.
* First value is appearance time, marking the note appearance this many seconds in advance.
* Second value is movement wait time, notes stay in place and start moving when this many seconds are left.
* Can be placed in the middle of a measure.
* Ignored in taiko-web.
;Example:
#SUDDEN 2 1
1122,
==== #JPOSSCROLL ''(?)'' ====
* Linearly transition cursor's position to a different position on a bar.
* Value is a space-separated array:
:* First value is the amount of seconds it takes for cursor to transition. If it takes too long before another #JPOSSCROLL is passed, it will be cancelled and next transition will happen at the cursor's current position.
:* Second value is the relative distance in pixels to move the cursor.
:* Third value is the direction, "0" is left and "1" is right.
* Can be placed in the middle of a measure.
* Ignored in taiko-web.
;Example
#BPMCHANGE 120
#JPOSSCROLL 2 760 1
#SCROLL 0.8
1111,
#JPOSSCROLL 2 760 0
#SCROLL -0.8
2222,
== Further reading ==
* [https://web.archive.org/web/20190914085205/https://aioilight.space/taiko/tjap3/doc/tja/ ".tja フォーマット"] (.tja format) - AioiLight.space.
* [https://wikiwiki.jp/jiro/%E5%A4%AA%E9%BC%93%E3%81%95%E3%82%93%E6%AC%A1%E9%83%8E "仕様"] (Specifications) - 太鼓さん次郎交流 Wiki (Taiko Jiro Kouryuu Wiki).
* [http://taikosanjiro.hatenablog.com/entry/%E8%AD%9C%E9%9D%A2-2 "譜面追加 自分で作る"] (Adding charts, creating one yourself) - 太鼓さん次郎解説 (Taiko Jiro Kaisetsu).
* [https://github.com/AioiLight/TJAPlayer3/blob/master/TJAPlayer3/Songs/CDTX.cs "CDTX.cs"] - AioiLight/TJAPlayer3 repository.

616
tja/1.tja
View File

@@ -1,616 +0,0 @@
//TJADB Project
TITLE:Tenjiku 2000
TITLEJA:ÄñÀ­2000
SUBTITLE:--Linda AI-CUE
BPM:180
WAVE:Tenjiku 2000.ogg
OFFSET:-5.428
DEMOSTART:11.226
COURSE:Oni
LEVEL:10
BALLOON:6,6,6,6
SCOREINIT:450
SCOREDIFF:108
#START
,
,
#MEASURE 3/4
,
,
,
#MEASURE 4/4
12021020,
#MEASURE 3/4
102220102220,
102220102220,
100100200000100100200000100200102020,
#MEASURE 4/4
1000222010002220,
100000000000200200200000200200200000202020200000,
1000222010002220,
1000100022202220,
1000222010002220,
100000000000200200200000200200200000202020200000,
1012,
#MEASURE 3/4
102220102220,
100222200100222200,
100100200000100100200000100200102020,
#MEASURE 4/4
1000222010002220,
100000000000200200200000200200200000202020200000,
1000222010002220,
1000100022202220,
1000222010002220,
100000000000100200200200100200200100202020100000,
1,
#MEASURE 2/4
04,
#MEASURE 4/4
0000112011201022,
1000221012201000,
100100200000100100200000101010200000101010200000,
1000002210000000,
0000112011201022,
1000221012201000,
200200100000200200100000202020100000202020100000,
1000001120000000,
21112121,
1121102011211000,
12221212,
200200102020100000100000200200102020100000000000,
000222200100100000300000,
000222200100100000400000,
100000200200100000200200100000202020100000200000,
100100200000100100200000101010200000101010200000,
500000000000000008000000000000202020200000000000,
500000000000000008000000000000202020200000000000,
500000000000000008000000000000202020200000000000,
500000000000000008000000202020002020200000200000,
1201021012010210,
1201021012010210,
1000001110201020,
100100100100100000100000200000200000202020202000,
,
#MEASURE 5/4
11222011222011222010,
#MEASURE 4/4
100000000000111200111200,
1122201122201122,
#MEASURE 2/4
2,
#MEASURE 4/4
78,
1000100011101000,
78,
1000100011101000,
100000200200100000222200,
100000200200100000222200,
100222200100100222200100,
100000000000111011100100,//64
#GOGOSTART
78,
2222200022222000,
78,
2222200022222000,
1111221022202000,
1122112011101000,
#MEASURE 3/4
100222200100222200,
100100202020200000100100202020200000,
100100202020100100200000300000300000,//73
#GOGOEND
#MEASURE 4/4
,
13,
13,
#MEASURE 5/4
1011011003,//77
#MEASURE 4/4
#GOGOSTART
2222202222202210,
1120221010112020,
1011201111201022,
2020101020111020,
1000221000112000,
1220102220404000,
0022102011201020,
100100100100200000100000200200200200202020200000,//85
#GOGOEND
4,
#MEASURE 5/4
100100202020200000100100202020200000100100202020200000100000,
#MEASURE 4/4
100000000000111200111200,
100100202020200000100100202020200000100100202020,
#MEASURE 2/4
2230,
#MEASURE 4/4
02021020,
#MEASURE 3/4
100222200100222200,
100100202020200000100100202020200000,
111200111200100111,
#MEASURE 4/4
1000202010002220,
100000000000200200200000200200200000202020200000,
1000222010002220,
100000100000222200222200,
1000222010002220,
100000000000100200200200100200200100202020100000,
1,
#MEASURE 3/4
221111,
#MEASURE 5/4
200020001000100010001000200200100100000000200200100100000000,
#MEASURE 3/4
221100221100221100,
#MEASURE 4/4
221100222222200000000100,
3,
#END
COURSE:Hard
LEVEL:8
BALLOON:5,5,5,5,20
SCOREINIT:470
SCOREDIFF:115
#START
,
,
#MEASURE 3/4
,
,
,
#MEASURE 4/4
12021020,
#MEASURE 3/4
11,
102220102220,
100010001212,
#MEASURE 4/4
1000222010002220,
1000200022202220,
1000222010002220,
1000100022202220,
1000222010002220,
1000200022202220,
1012,
#MEASURE 3/4
11,
102220102220,
100010001212,
#MEASURE 4/4
1000222010002220,
1000200022202220,
1000222010002220,
1000100022202220,
1000222010002220,
1000100010010010,
1,
#MEASURE 2/4
04,
#MEASURE 4/4
0111,
1000112011201000,
1110111011101110,
1000002220000000,
0111,
1000112011201000,
2220222022202220,
2000001110000000,
21112121,
1110111011111000,
12221212,
2220222022222000,
0022201010003000,
0022201010004000,
12121212,
1120112011201000,
500000000000000008000000000000000000000000000000,
500000000000000008000000000000000000000000000000,
500000000000000008000000000000200200200000000000,
500000000000000008000000000000000000000000000000,
1001001010010010,
1001001010010010,
10011010,
1110111022202220,
,
#MEASURE 5/4
11111011111011111000,
#MEASURE 4/4
1,
2222202222202222,
#MEASURE 2/4
2,
#MEASURE 4/4
78,
1000100011101000,
78,
1000100011101000,
10221020,
10221020,
1022200010222000,
1011,//64
#GOGOSTART
78,
2000200022202000,
78,
2000200022202000,
1111100020002000,
1111100020002000,
#MEASURE 3/4
102220102220,
102220102220,
333,//73
#GOGOEND
#MEASURE 4/4
,
13,
13,
#MEASURE 5/4
1011011000,//77
#MEASURE 4/4
#GOGOSTART
2220202220202220,
1011101010111000,
1011100010111000,
2220111022222000,
1000221000112000,
1110101022204000,
0022200011100000,
1111100022222000,//85
#GOGOEND
4,
#MEASURE 5/4
11111011111011111000,
#MEASURE 4/4
1,
2222202222202222,
#MEASURE 2/4
2,
#MEASURE 4/4
02021020,
#MEASURE 3/4
11,
102220102220,
101011,
#MEASURE 4/4
1000222010002220,
1000200022202220,
1000222010002220,
1000100022202220,
1000222010002220,
1000200022202220,
1,
#MEASURE 3/4
221111,
#MEASURE 5/4
200020001000100010001000200200100100000000000000700000000000,
#MEASURE 3/4
,
#MEASURE 4/4
08,
3,
,
,
#END
COURSE:Normal
LEVEL:7
BALLOON:8,8,8,8,15
SCOREINIT:990
SCOREDIFF:298
#START
,
,
#MEASURE 3/4
,
,
,
#MEASURE 4/4
11011000,
#MEASURE 3/4
11,
11,
111,
#MEASURE 4/4
1,
,
11,
1100,
11,
500000000000000000000000000008000000000000000000,
11011000,
#MEASURE 3/4
11,
11,
111,
#MEASURE 4/4
1,
,
11,
1100,
11,
500000000000000000000000000000000000000000000008,
,
#MEASURE 2/4
04,
#MEASURE 4/4
0111,
11,
1111,
12,
0111,
11,
2222,
21,
2121,
2220,
1212,
1110,
2220,
1110,
1111,
2220,
500000000000000000000008000000000000000000000000,
500000000000000000000008000000000000000000000000,
500000000000000000000008000000000000000000000000,
500000000000000000000008000000000000000000000000,
11,
11,
1,
33,
12021020,
#MEASURE 5/4
1001001010,
#MEASURE 4/4
12021020,
10010010,
#MEASURE 2/4
1,
#MEASURE 4/4
9009,
0800000000000000,
9009,
0800000000000000,
1212,
1212,
11,
1,//64
#GOGOSTART
9009,
0800000000000000,
9009,
0800000000000000,
2220,
1110,
#MEASURE 3/4
110110,
110110,
110330,//73
#GOGOEND
#MEASURE 4/4
3,
13,
13,
#MEASURE 5/4
1,//77
#MEASURE 4/4
#GOGOSTART
22022020,
1110,
11011010,
2220,
10120120,
12012040,
02201100,
1122,//85
#GOGOEND
4,
#MEASURE 5/4
1001001010,
#MEASURE 4/4
1,
20020020,
#MEASURE 2/4
2,
#MEASURE 4/4
11011000,
#MEASURE 3/4
11,
11,
111,
#MEASURE 4/4
1,
,
11,
1100,
11,
500000000000000000000000000000000008000000000000,
1,
#MEASURE 3/4
220000,
#MEASURE 5/4
220000700000000,
#MEASURE 3/4
,
#MEASURE 4/4
0000000008000000,
3,
,
,
#END
COURSE:Easy
LEVEL:4
BALLOON:6,6,6,6,15
SCOREINIT:750
SCOREDIFF:258
#START
,
,
#MEASURE 3/4
,
,
,
#MEASURE 4/4
11,
#MEASURE 3/4
11,
11,
1,
#MEASURE 4/4
1,
,
0101,
0100,
0101,
500000000000000000000000000008000000000000000000,
11,
#MEASURE 3/4
11,
11,
1,
#MEASURE 4/4
1,
,
0101,
0100,
0101,
500000000000000000000000000000000000000000000008,
,
#MEASURE 2/4
,
#MEASURE 4/4
0101,
0101,
0101,
0220,
0202,
0202,
0202,
0110,
0202,
0222,
0101,
0111,
0202,
0101,
0111,
0111,
500000000000000000000000000000000000000000000008,
,
500000000000000000000000000000000000000000000008,
,
11,
11,
1,
33,
3,
#MEASURE 5/4
1001001010,
#MEASURE 4/4
,
10010010,
#MEASURE 2/4
1,
#MEASURE 4/4
9009,
0800000000000000,
9009,
0800000000000000,
0202,
0202,
0202,
0200,//64
#GOGOSTART
9009,
0800000000000000,
9009,
0800000000000000,
1110,
1110,
#MEASURE 3/4
111,
111,
111,//73
#GOGOEND
#MEASURE 4/4
1,
1,
1,
#MEASURE 5/4
1,//77
#MEASURE 4/4
#GOGOSTART
0202,
0222,
0202,
0222,
0101,
0111,
0202,
0222,//85
#GOGOEND
4,
#MEASURE 5/4
1001001010,
#MEASURE 4/4
,
20020020,
#MEASURE 2/4
2,
#MEASURE 4/4
1,
#MEASURE 3/4
11,
11,
1,
#MEASURE 4/4
1,
,
0101,
0101,
0202,
500000000000000000000000000000000000000000000008,
,
#MEASURE 3/4
7,
#MEASURE 5/4
,
#MEASURE 3/4
,
#MEASURE 4/4
08,
3,
,
,
#END

View File

@@ -1,29 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}