diff --git a/docs/bms-spec.ja.md b/docs/bms-spec.ja.md index e6e6660e..c13706ff 100644 --- a/docs/bms-spec.ja.md +++ b/docs/bms-spec.ja.md @@ -94,18 +94,19 @@ - [x] `#PLAYER=4` (BATTLE) はメタ情報として保持し、現状は専用の 2 人対戦プレイを実装しない - [x] チャンネル `D1-D9` (地雷) を解釈 - [x] チャンネル `E1-E9` (地雷) を解釈 -- [x] MANUAL モードで地雷タイミング入力を `BAD` 判定に反映 -- [x] MANUAL モードの地雷ダメージに譜面の ID base で解釈したオブジェクト値 (`object value / 2`) を適用しつつ、判定表示は `BAD` のままにする +- [x] MANUAL モードで「キー ON かつ `GOOD` 窓以内」の地雷を爆発させる(押下時・押しっぱなし通過の両方。LR2 準拠) +- [x] 地雷ダメージはオブジェクト値(大文字 base36)をそのままパーセントとして適用し、判定・コンボには影響させない(LR2 / beatoraja 準拠) - [x] `#WAV00` が定義されている場合、MANUAL モードの地雷ヒットで爆発音として使用 - [x] 地雷を `TOTAL` / `EX-SCORE` の対象ノート数から除外 #### 地雷ダメージの根拠 -地雷ダメージは BM98 時代の基礎 BMS 仕様には含まれていないため、現実装では後年公開された地雷拡張系の資料を根拠にしています。 +地雷ダメージは BM98 時代の基礎 BMS 仕様には含まれていないため、現実装では後年公開された地雷拡張系の資料と LR2 互換実装を根拠にしています。 -- 地雷ダメージを `value / 2` とする根拠は Hitkey の command memo です。`[01-ZZ]` をダメージ量とし、ゲージが `value / 2` だけ減る整理に従います。 -- `#WAV00` を地雷リアクション専用とする扱いと、`ZZ` を即死級の値とみなす根拠は Obj Tech Lovers chapter3-2 / chapter4-7 で補強しています。 -- `be-music` では groove gauge を LR2 互換の `2-100%` で実装しているため、`ZZ` の実際の効果はゲージ下限 `2%` への clamp です。 +- 発動条件「キー ON かつ判定線との間隔が `GOOD` 判定以内(押しっぱなし通過を含む)」と「ダメージ = 指定値そのまま(10進換算%)」は losak「地雷オブジェに関するアレコレ (LR2)」の LR2 実機検証に従います。beatoraja(jbms-parser `Section.java` / `JudgeManager`)も生値をダメージとして直接適用しており一致します。 +- Hitkey command memo の `value / 2` は nanasi 系統の仕様であり、LR2 の実挙動とは異なるため採用していません。 +- `#WAV00` を地雷リアクション専用とする扱いは Hitkey memo / Obj Tech Lovers chapter3-2 / chapter4-7 で補強しています。 +- `ZZ`(= 1295%)は survival 系ゲージ(HARD / DEATH)では即 FAILED、`2-100%` の GROOVE / EASY ではゲージ下限 `2%` への clamp になります(losak の「EASY / GROOVE なら 2% になるだけ」と一致)。 - [x] チャンネル `SC` を `#SCROLLxx` 参照イベントとして保持 - [x] チャンネル `SC` を音声トリガー対象から除外 - [x] チャンネル `SC` のスクロール速度を player 描画へ反映 @@ -299,7 +300,7 @@ runtime と round-trip の扱い: - [ ] 未定義 `#BPMxx` / `#STOPxx` 参照時の互換挙動(無視・既定値・エラー) - [ ] `#STOPxx` 空定義参照(例: `#05209:` の未定義トークン)時の互換挙動 - [ ] 行頭インデント付きコマンド(先頭空白 + `#COMMAND`)の受理方針 -- [ ] 制御構文の別表記 `#ELSE IF` / `#END IF` / `#END` の受理方針 +- [x] 制御構文の別表記 `#ELSE IF n` / `#END IF` / 値なし `#END` を `#ELSEIF n` / `#ENDIF` として受理(保存・出力時は正規形へ正規化、`#END <他の値>` は未知ヘッダのまま) - [ ] `#IF` / `#SWITCH` ブロック未終端(`#ENDIF` / `#ENDSW` 欠落)時の EOF 補完規則 - [x] Bemuse 拡張ヘッダ `#SPEEDxx` の受理と実行時反映 - [x] Bemuse 拡張チャンネル `#xxxSP`(spacing factor)の受理と描画反映 @@ -354,6 +355,7 @@ runtime と round-trip の扱い: - `AUTO` / `AUTO SCRATCH` / `MANUAL` のいずれでも、再生音声はリアルタイムトリガ方式を使用する - `--play-volume` は演奏レーン系、`--bgm-volume` は非演奏レーン系へ適用する - `SC` / 地雷 / `LNOBJ` 終端抑止対象イベントは音声トリガ対象から除外する +- 未定義・ファイル欠落・デコード失敗の `#WAVxx` 参照は無音として扱う(デバッグ用に `missingSampleToneSeconds` オプションで代替トーンを有効化できる) ### SCROLL/BPM/STOP 互換ポリシー @@ -367,14 +369,19 @@ runtime と round-trip の扱い: ## イベント位置の扱い - `data` は 2文字単位で分割し、`00` は空イベント +- `data` は最初の空白文字で打ち切り、それ以降の文字列は無視する - 位置は `position: [numerator, denominator]` として保持 - `denominator = トークン数` - `numerator = トークンの0始まりインデックス` ## 文字コード -- BOM 付き UTF-8 / UTF-16LE / UTF-16BE を優先 -- BOM がない場合は `shift_jis`, `utf8`, `euc-jp`, `latin1` をスコアリングして推測 +- BOM 付き UTF-8 / UTF-16LE / UTF-16BE を最優先で採用 +- BOM がない場合は `#CHARSET` 宣言を解決(対応エンコーディングに正規化できた場合のみ) +- ASCII のみのファイルは UTF-8 として扱う +- 厳密 UTF-8 検証(マルチバイト列を含み fatal デコードに成功)を通れば UTF-8 とみなす +- 上記以外は `shift_jis`, `utf8`, `euc-jp`, `latin1` をスコアリングして推測 +- この判定は `@be-music/parser` の `decodeBmsText` に一本化されており、CLI / TUI / Web のすべてが同じ結果になる ## stringifier ルール diff --git a/docs/bms-spec.md b/docs/bms-spec.md index 8cd83c4b..7f93b662 100644 --- a/docs/bms-spec.md +++ b/docs/bms-spec.md @@ -94,18 +94,19 @@ However, the exact specification of control syntax compatibility for files conta - [x] `#PLAYER=4` (BATTLE) will be retained as meta information, and dedicated two-player competitive play will not be implemented at this time. - [x] Interpret channel `D1-D9` (mine) - [x] Interpret channel `E1-E9` (mine) -- [x] Reflect mine timing input in `BAD` judgment in MANUAL mode -- [x] Apply mine object value as MANUAL mode groove gauge damage (`object value / 2` under the chart's ID base) while keeping the judgment display as `BAD` +- [x] Detonate mines in MANUAL mode while "the key is ON and the mine is within the `GOOD` window" (both press and hold-through, LR2 behavior) +- [x] Apply the mine object value (upper-case base36) directly as the gauge-damage percentage, with no effect on judgments or combo (LR2 / beatoraja behavior) - [x] When `#WAV00` is defined, use it as the landmine explosion sound on manual mine hit - [x] Exclude landmines from the number of target notes for `TOTAL` / `EX-SCORE` #### Landmine damage basis -The original BM98-era core BMS specifications do not define landmine damage as part of the base format, so this implementation follows later public extension references. +The original BM98-era core BMS specifications do not define landmine damage as part of the base format, so this implementation follows later public extension references and LR2-compatible implementations. -- `value / 2` for landmine damage is based on Hitkey's command memo (`[01-ZZ]` damage amount, gauge decreases by `value / 2`). -- `#WAV00` as the dedicated landmine reaction sound and the `ZZ` instant-death convention are corroborated by Obj Tech Lovers chapter3-2 and chapter4-7. -- In `be-music`, the practical effect of `ZZ` is a clamp to the implemented groove gauge minimum `2%`, because the player keeps the LR2-compatible `2-100%` gauge range. +- The detonation condition ("key ON and the mine within the `GOOD` window of the judge line, including hold-through") and "damage = the value itself (as a decimal percentage)" follow losak's LR2 mine writeup ("地雷オブジェに関するアレコレ"), verified against the real LR2. beatoraja (jbms-parser `Section.java` / `JudgeManager`) applies the raw value directly as damage, matching this. +- Hitkey command memo's `value / 2` is the nanasi-lineage rule and differs from LR2's actual behavior, so it is not adopted. +- `#WAV00` as the dedicated landmine reaction sound is corroborated by Hitkey's memo and Obj Tech Lovers chapter3-2 / chapter4-7. +- `ZZ` (= 1295 %) instantly FAILs survival gauges (HARD / DEATH); on the `2-100%` GROOVE / EASY gauges it clamps to the `2%` floor (matching losak's "EASY / GROOVE just drop to 2%"). - [x] Keep channel `SC` as `#SCROLLxx` reference event - [x] Exclude channel `SC` from audio triggering - [x] Reflect scroll speed of channel `SC` to player drawing @@ -299,7 +300,7 @@ Also, volume changes will only be reflected in the initial gain of new sounds th - [ ] Compatible behavior when referencing undefined `#BPMxx` / `#STOPxx` (ignored, default value, error) - [ ] `#STOPxx` Compatibility behavior when empty definition reference (e.g. undefined token of `#05209:`) - [ ] Acceptance policy for commands with leading indentation (leading blank + `#COMMAND`) -- [ ] Acceptance policy for alternative notation of control syntax `#ELSE IF` / `#END IF` / `#END` +- [x] Accept the alternative spellings `#ELSE IF n` / `#END IF` / bare `#END` as `#ELSEIF n` / `#ENDIF` (normalized to the canonical form on save / output; `#END ` remains an unknown header) - [ ] EOF completion rules when `#IF` / `#SWITCH` block is unterminated (`#ENDIF` / `#ENDSW` is missing) - [x] Acceptance of Bemuse extension header `#SPEEDxx` and reflection in note drawing distance - [x] Bemuse expansion channel `#xxxSP` (spacing factor) acceptance and drawing reflection @@ -354,6 +355,7 @@ Also, volume changes will only be reflected in the initial gain of new sounds th - Playback audio uses real-time trigger method for any of `AUTO` / `AUTO SCRATCH` / `MANUAL` - `--play-volume` applies to performance lanes, `--bgm-volume` applies to non-performance lanes. - `SC` / Landmine / `LNOBJ` Exclude terminal suppression target events from audio trigger targets +- Undefined, missing, or undecodable `#WAVxx` references are treated as silence (a substitute debug tone can be enabled with the `missingSampleToneSeconds` option) ### SCROLL/BPM/STOP compatibility policy @@ -367,14 +369,19 @@ Also, volume changes will only be reflected in the initial gain of new sounds th ## Handling of event location - `data` splits by two characters, `00` is empty event +- `data` is truncated at the first whitespace character; everything after it is ignored - Position is kept as `position: [numerator, denominator]` - `denominator = number of tokens` - `numerator = zero-based token index` ## Character Encoding -- Prefer UTF-8 / UTF-16LE / UTF-16BE with BOM -- If BOM is missing, infer by scoring `shift_jis`, `utf8`, `euc-jp`, `latin1` +- Adopt UTF-8 / UTF-16LE / UTF-16BE with BOM first +- If there is no BOM, resolve the `#CHARSET` declaration (only when it canonicalizes to a supported encoding) +- ASCII-only files are treated as UTF-8 +- A buffer that passes strict UTF-8 validation (fatal decode succeeds with multibyte sequences present) is UTF-8 +- Otherwise infer by scoring `shift_jis`, `utf8`, `euc-jp`, `latin1` +- This detection is unified in `@be-music/parser`'s `decodeBmsText`, so CLI / TUI / Web all produce the same result ## stringifier rules diff --git a/docs/bmson-spec.ja.md b/docs/bmson-spec.ja.md index d365986e..b26ca3ef 100644 --- a/docs/bmson-spec.ja.md +++ b/docs/bmson-spec.ja.md @@ -33,6 +33,7 @@ - [x] `info` 拡張項目: `mode_hint` - [x] `info` 拡張項目: `judge_rank` - [x] `info` 拡張項目: `total` +- [x] beatoraja 拡張 `info.ln_type` と note 単位 `t`(1: LN / 2: CN / 3: HCN)を IR に保持し、player の LN モード解決と stringifier 出力に使用 - [x] `info` 拡張項目: `back_image` - [x] `info` 拡張項目: `eyecatch_image` - [x] `info` 拡張項目: `banner_image` @@ -47,7 +48,7 @@ - [x] `info.back_image`, `info.banner_image`, `info.eyecatch_image` を unified `metadata.backBmp`, `metadata.banner`, `metadata.stageFile` へ mirror - [x] 明示的な `lines: []` を barline suppression として扱い、`bmson.barlinesSuppressed` に保持 - [x] `lines` 欠落時は bmson 既定の 4/4 model として扱う -- [x] `info.total` 欠落または非正値は bmson 既定値 `100` として扱う +- [x] `info.total` 欠落・非数は bmson 既定値 `100`、負値は仕様どおり絶対値、`0` は保持(ライフバーが増えない)として扱う - [x] `info.init_bpm` 欠落、0、負値、非数値は parse error として扱う - [ ] `version` の妥当性検証(`null` のエラー化、未指定時の legacy 扱い方針) - [ ] `version` の互換判定を SemVer で行う方針の明文化 @@ -132,7 +133,7 @@ - `info.resolution` を `bmson.info.resolution` に保持 - 互換としてルート `resolution` も読み取り、`info.resolution` がなければ採用 - `info.init_bpm` が欠落、または正の有限数ではない場合は document を reject -- `info.total` が有限数ならそのまま使い、それ以外は bmson 既定値 `100` を適用 +- `info.total` が有限数なら使い(負値は仕様どおり絶対値へ正規化)、それ以外は bmson 既定値 `100` を適用 - `info.back_image`, `info.banner_image`, `info.eyecatch_image` を normalized metadata image field に mirror - `sound_channels[i].name` を `resources.wav[key]` に登録 - `key = base36(i + 1)` を2桁化 diff --git a/docs/bmson-spec.md b/docs/bmson-spec.md index 872a39f7..d8a1c352 100644 --- a/docs/bmson-spec.md +++ b/docs/bmson-spec.md @@ -33,6 +33,7 @@ This document defines how `packages/parser` / `packages/stringifier` handles BMS - [x] `info` extended item: `mode_hint` - [x] `info` extended item: `judge_rank` - [x] `info` extended item: `total` +- [x] Preserve the beatoraja extensions `info.ln_type` and per-note `t` (1: LN / 2: CN / 3: HCN) in the IR, used for the player's LN mode resolution and stringifier output - [x] `info` extended item: `back_image` - [x] `info` extended item: `eyecatch_image` - [x] `info` extended item: `banner_image` @@ -47,7 +48,7 @@ This document defines how `packages/parser` / `packages/stringifier` handles BMS - [x] Mirror `info.back_image`, `info.banner_image`, and `info.eyecatch_image` into unified `metadata.backBmp`, `metadata.banner`, and `metadata.stageFile` - [x] Treat an explicit `lines: []` as barline suppression and keep it in `bmson.barlinesSuppressed` - [x] Treat missing `lines` as the bmson 4/4 default model -- [x] Treat missing or non-positive `info.total` as the bmson default `100` +- [x] Treat missing / non-numeric `info.total` as the bmson default `100`, take the absolute value of a negative one, and keep `0` (the lifebar does not increase) - [x] Treat missing, zero, negative, or non-numeric `info.init_bpm` as a parse error - [ ] Validation of `version` (turning `null` into an error, treating legacy as unspecified) - [ ] Clarification of policy for determining compatibility of `version` with SemVer @@ -132,7 +133,7 @@ This document defines how `packages/parser` / `packages/stringifier` handles BMS - Keep `info.resolution` in `bmson.info.resolution` - Also read the root `resolution` for compatibility, and adopt it if `info.resolution` is not present. - Reject the document when `info.init_bpm` is missing or not a positive finite number. -- Use `info.total` as-is when finite, otherwise apply the bmson default `100`. +- Use `info.total` when finite (normalizing a negative value to its absolute value per the spec), otherwise apply the bmson default `100`. - Mirror `info.back_image`, `info.banner_image`, and `info.eyecatch_image` into normalized metadata image fields. - Register `sound_channels[i].name` in `resources.wav[key]` - Convert `key = base36(i + 1)` to 2 digits diff --git a/docs/player-spec.ja.md b/docs/player-spec.ja.md index 41254582..3ca95b0d 100644 --- a/docs/player-spec.ja.md +++ b/docs/player-spec.ja.md @@ -175,14 +175,17 @@ kitty 非対応端末へフォールバックした場合、reverse scratch の ### 基準幅 -player はまず IIDX 系の基準判定幅を持ちます。 -以後の rank 解決や拡張命令は、この基準値を倍率で拡縮する形で適用します。 +player は LR2 の実測判定幅を基準にします(hitkey 日記 2015-01-19 の実測値、lr2oraja の LR2 互換テーブルと一致)。 -- `PGREAT`: `16.67ms` -- `GREAT`: `33.33ms` -- `GOOD`: `116.67ms` -- `BAD`: `250ms` +| `#RANK` | `PGREAT` | `GREAT` | `GOOD` | `BAD` | +| --- | --- | --- | --- | --- | +| `0` `VERY HARD` | `±8ms` | `±24ms` | `±40ms` | `±200ms` | +| `1` `HARD` | `±15ms` | `±30ms` | `±60ms` | `±200ms` | +| `2` `NORMAL` | `±18ms` | `±40ms` | `±100ms` | `±200ms` | +| `3` `EASY` | `±21ms` | `±60ms` | `±120ms` | `±200ms` | +| `4` `VERY EASY` | `NORMAL` と同一(LR2 は `#RANK 4` を `NORMAL` として扱う) | | | | +`BAD` 幅は rank・拡張命令に関わらず `±200ms` で固定です。スクラッチも鍵盤と同じ幅を使います。 `PERFECT` / `GREAT` / `GOOD` / `BAD` / `POOR` の境界は、この 4 本の幅から決まります。 `POOR` は `BAD` 幅を超えた入力、またはノートの取り逃しで発生します。 @@ -198,27 +201,20 @@ BMS では、再生開始時点の判定幅を次の優先順位で決めます `100` は基準値であり、`NORMAL` と同じ幅です。 player は `#DEFEXRANK` を `Number.parseFloat()` で解釈し、有限かつ `0` より大きい値だけを採用します。 -`#RANK` は beatoraja 互換の倍率テーブル `[25, 50, 75, 100, 125]` として扱います。 +`#RANK` は内部の judgerank パーセント軸 `[25, 50, 75, 100, 75]`(`VERY HARD`=25 / `HARD`=50 / `NORMAL`=75 / `EASY`=100 / `VERY EASY`=`NORMAL` 扱い)へ写像します。 `metadata.rank` は整数へ切り捨てて解釈し、範囲外の値は無効として既定値へフォールバックします。 -- `#RANK 0`: `25%` (`VERY HARD`) -- `#RANK 1`: `50%` (`HARD`) -- `#RANK 2`: `75%` (`NORMAL`) -- `#RANK 3`: `100%` (`EASY`) -- `#RANK 4`: `125%` (`VERY EASY`) - ### BMS の換算式 -BMS の実際の判定幅は、`NORMAL = 75%` を基準にして計算します。 -たとえば `#DEFEXRANK 120` は「基準判定幅の `1.2` 倍」であり、`PGREAT=20.004ms`, `GREAT=39.996ms`, `GOOD=140.004ms`, `BAD=300ms` として扱います。 - -`#RANK` から解決した値も同じ式で扱います。 -たとえば `#RANK 4` は `125 / 75` 倍なので、`VERY EASY` は `NORMAL` より約 `1.666...` 倍広い判定幅です。 +判定幅は、上の実測テーブルを judgerank パーセント軸上のアンカー(25 / 50 / 75 / 100)として区分線形補間して求めます(lr2oraja の `JudgeWindowRule.LR2` と同じモデル)。 +`#DEFEXRANK n` は `n × 75 / 100` でパーセント軸へ換算します。たとえば `#DEFEXRANK 120` は percent `90` であり、`PGREAT=19.8ms`, `GREAT=52ms`, `GOOD=112ms`, `BAD=200ms`(固定)になります。 +`EASY`(percent `100`)を超える値は最終区間の傾きで外挿し、スケール対象は `PGREAT` / `GREAT` / `GOOD` のみです。どれだけ広げても各幅は固定の `BAD` 幅 `±200ms` を超えません。 ### BMS の動的判定幅変更 BMS では `#xxxA0` チャンネルと `#EXRANKxx` を使って、演奏途中で判定幅を変更できます。 player は `A0` チャンネルのイベント値を `#EXRANKxx` のキーとして解決し、その値を `Number.parseFloat()` で読んで、有限かつ `0` より大きい場合だけ採用します。 +採用した値は `#DEFEXRANK` と同じ「`RANK 2 = 100`」基準の百分率として解釈するため、`#EXRANKxx 100` はちょうど `NORMAL` の判定幅に戻ります。 `#EXRANKxx` が未定義、空文字列、非数、`0` 以下の場合、そのイベントは判定幅を変更しません。 複数の `A0` イベントがある場合は、時刻順に適用し、後から到達した値が以後の判定幅になります。 @@ -242,8 +238,8 @@ bmson では次の優先順位で判定幅を決めます。 2. `metadata.rank` 3. 既定値 `100` -bmson の基準値は `100%` です。 -そのため `judgeRank=100` は IIDX 系の基準判定幅そのままで、`50` は半分、`150` は `1.5` 倍として扱います。 +bmson の `judgeRank` は `#DEFEXRANK` と同じ「`100` = `NORMAL`」基準の百分率として扱い、同じ LR2 アンカー補間で判定幅へ換算します。 +そのため `judgeRank=100` は `NORMAL`(`±18/±40/±100/±200ms`)そのままです。 現在の実装では、bmson に BMS の `#EXRANKxx` 相当の動的判定幅変更はありません。 @@ -315,10 +311,13 @@ bmson の基準値は `100%` です。 空POOR は LR2 における phantom press の扱いに合わせ、次の挙動とします。 +- 発生条件は **同レーンのノートが押下時刻から 1 秒以内の未来にあること** です(lr2oraja の LR2 ミス窓 `{0, 1000000}`µs。rank / EXRANK に依存しない固定窓)。ノート通過後(遅い側)に空POOR は発生せず、1 秒以内に次のノートが無いレーンの空打鍵は keysound 再生のみで無害です。 +- 同一ノートの手前であれば連打で **何度でも** 発生します(LR2 の `MissCondition.ALWAYS`。判定済みノートの手前でも発生)。 + - `summary` のジャッジカウンタ (`perfect`/`great`/`good`/`bad`/`poor`) は **更新しない**。 LR2 では「見逃しPOOR」(NOWJUDGE index 1) のみが POOR としてカウントされ、「空POOR」(index 0) はカウント外となるため。 - EX-SCORE / IIDX score は **変化させない**。 - combo は **切らない**。 -- groove gauge には `EMPTY_POOR` を適用する。デルタは [`groove-gauge.ts`](../packages/player/src/core/groove-gauge.ts) の `applyGrooveGaugeJudge('EMPTY_POOR')` 経由で算出され、 GROOVE / HARD で `-2`、 EASY で `-1`、 DEATH では `-100` (= 即時 0%)。 NORMAL / EASY ではほぼ無害だが、 HARD / DEATH では実害が出る。 +- groove gauge には `EMPTY_POOR` を適用する。デルタは [`groove-gauge.ts`](../packages/player/src/core/groove-gauge.ts) の `applyGrooveGaugeJudge('EMPTY_POOR')` 経由で算出され、 GROOVE で `-2`、 HARD で `-2`(TOTAL 補正対象)、 EASY で `-1.6`、 DEATH では `-10`。 NORMAL / EASY ではほぼ無害だが、 HARD / DEATH では実害が出る。 - **POOR BGA を発火する** (`trigger-poor-bga`)。 - **judge 表示を `POOR` で 0.6 秒フラッシュ** する (`publishJudgeCombo('POOR', combo)`)。 LR2 spec 上は op 246 (1P 空POOR) / 266 (2P 空POOR) と op 245 / 265 (見逃しPOOR) が分岐するが、本実装では NOWJUDGE index 0 / 1 を同じ `'poor'` skin slot に解決しているため、視覚上は同一の POOR 表示になる。 @@ -330,13 +329,14 @@ LN 解放直後の repeat-suppress 窓内も同様に空POOR を発火させま ### 地雷 -手動入力時に地雷候補が通常ノート候補より近いか同距離なら、地雷を優先します。 -地雷は `BAD` として扱い、combo を切ります。 -groove gauge ダメージは地雷オブジェクト値を大文字 base36 として解釈し、`damage = value / 2` で計算します。 -適用後のゲージ値は `2-100%` に clamp されるため、`ZZ` のような大きい値では実質 `2%` まで下がります。 -`#WAV00` が定義されている場合は、地雷ヒット経路でそのサンプルを鳴らします。 +地雷は LR2 の発動モデルに合わせます(losak「地雷オブジェに関するアレコレ」、beatoraja `JudgeManager` で確認)。 -このルールの根拠は [`bms-spec.ja.md`](./bms-spec.ja.md) に記載しています。`value / 2` の直接根拠は Hitkey の command memo で、`#WAV00` / `ZZ` の扱いは Obj Tech Lovers chapter3-2 / chapter4-7 を補助一次参照として採っています。 +- 発動条件は「**レーンのキーが ON かつ 地雷が判定線の `GOOD` 窓以内**」です。押下した瞬間に `GOOD` 圏内の地雷は爆発し、**押しっぱなしで通過した地雷も爆発**します。キーが押されていない地雷の通過は無害です。 +- 爆発はゲージ減少と `#WAV00` 爆発音のみで、**判定・コンボ・スコアには一切影響しません**。通常ノートの判定は爆発と独立に行われます(地雷が近接ノートへの入力を吸い込むことはありません)。 +- ダメージは地雷オブジェクト値(大文字 base36)を **そのままパーセントとして解釈**します(LR2 / beatoraja 準拠。nanasi 系仕様の `value / 2` とは異なります)。bmson の `key_channels[].notes[].damage` が付いた地雷はその値を優先します。 +- ダメージは HARD の 30% 緩和・`#TOTAL` 補正の対象外です(beatoraja の `gauge.addValue()` 直接加算と同じ)。 +- `ZZ`(= 1295%)は survival 系(HARD / DEATH)では即 FAILED、GROOVE / EASY では下限 `2%` で止まります。 +- kitty keyboard protocol 入力では押下/解放の実状態を使います。release イベントの無いフォールバック入力では、LN 保持と同じ短い grace 窓で「押されている」を近似します。 ## NOTES・combo・score @@ -422,6 +422,13 @@ FREE ZONE、地雷、不可視オブジェクトは `noteCount` に含めませ ゲージ更新後の値は、現在の gauge type の min/max range に clamp します。 +`HARD` / `EASY` / `DEATH` の variant は LR2 の値(beatoraja `GaugeProperty` の `HARD_LR2` / `EASY_LR2` / `HAZARD_LR2`)に合わせます。 + +- `HARD`: 回復 `PGREAT/GREAT +0.1` / `GOOD +0.05`(TOTAL 非依存)、減少 `BAD -6` / `見逃しPOOR -10` / `空POOR -2`。減少には `#TOTAL` 補正表(`TOTAL ≥240` で `×1.0` から `<120` で `×10` まで)を掛け、ゲージが `30%` 未満のときはさらに `×0.6` に緩和します。 +- `EASY`: 増加は GROOVE の `1.2` 倍、減少は `0.8` 倍(`BAD -3.2` / `POOR -4.8` / `空POOR -1.6`)。クリア閾値は GROOVE と同じ `80%` です。 +- `DEATH`(LR2 HAZARD 相当): `PGREAT +0.15` / `GREAT +0.06` / `GOOD 0`、`BAD` / `見逃しPOOR` は `-100`(即死)、`空POOR -10`。 +- `HARD` / `DEATH` は `2%` 未満になった時点で `0%` に落ちて FAILED 確定(以後回復しません)。地雷ダメージなどの生デルタは guts・TOTAL 補正の対象外です。 + ## ロングノート ### NOTES の数え方 @@ -433,7 +440,8 @@ player はロングノートを 1 本につき 1 ノートとして扱います ### `#LNMODE` BMS の `#LNMODE` 未指定時は `1` として扱います。 -bmson と FREE ZONE は `#LNMODE` の対象外で、終端を持つノートとして扱います。 +bmson は beatoraja 拡張の `info.ln_type` と note 単位の `t`(1: LN / 2: CN / 3: HCN、`t` が `ln_type` より優先)でモードを決め、どちらも未指定の場合は LR2 準拠の既定として `1`(LN)を使います。 +FREE ZONE は `#LNMODE` の対象外で、終端を持つノートとして扱います。 ### Manual Play @@ -616,7 +624,7 @@ TUI の描画上限はデフォルト `60fps` です。 judge 済みノートでも、judge line を跨ぐまで、または `visibleUntilBeat` が切れるまでは描画を残します。 long note は body と tail を持つ 1 本のノートとして描画し、保持中は lane highlight も継続します。 -ノートの視覚距離は、`#SCROLLxx` / `#xxxSC` の piecewise-constant 係数と、`#SPEEDxx` / `#xxxSP` の piecewise-linear 補間係数を掛け合わせて積分した値で決めます。`#SPEEDxx` がない場合は常に `1`、同一 beat の複数 keyframe は後勝ちです。`#SPEEDxx` の値が負数、非数、未定義参照の場合、その keyframe は描画計算から無視します。 +ノートの視覚距離は、`#SCROLLxx` / `#xxxSC` の piecewise-constant 係数と、`#SPEEDxx` / `#xxxSP` の piecewise-linear 補間係数を掛け合わせて積分した値で決めます。`#SPEEDxx` がない場合は常に `1`、同一 beat の複数 keyframe は後勝ちです。最初の keyframe より前の区間は、最初の keyframe の値で一定です(Bemuse 参照実装に準拠)。`#SPEEDxx` の値が負数、非数、未定義参照の場合、その keyframe は描画計算から無視します。 ### TUI 以外の出力 diff --git a/docs/player-spec.md b/docs/player-spec.md index 5c2c378c..07510f29 100644 --- a/docs/player-spec.md +++ b/docs/player-spec.md @@ -131,12 +131,16 @@ bmson's `l`, FREE ZONE (`17` / `27`), BMS's `#LNOBJ`, and BMS legacy LN (`#mmm51 The terminal object of `#LNOBJ` itself is not left in the performance note string. Therefore, both LNs derived from `#LNOBJ` and LNs derived from `#mmm51-69` are worth 1 note per book on the player. -### Landmine +### Landmines -The mine channel is mapped to the corresponding playable lane and stored as a separate array. -Landmines are not included in `summary.total`, but when manually input, they may generate `BAD` with priority over normal notes. -If `#WAV00` is defined, the player uses it as the explosion sound when a landmine is hit manually. +Mines follow the LR2 detonation model (losak's LR2 mine writeup, confirmed against beatoraja's `JudgeManager`). +- A mine explodes while "the lane's key is ON and the mine is within the `GOOD` window of the judge line": pressing with a mine in range detonates it, and **holding through a passing mine detonates it too**. A mine passing with the key up is harmless. +- An explosion only drains the gauge and plays the `#WAV00` explosion sample — **no verdict, no combo break, no score change**. Regular note judgment runs independently of detonation (a mine never swallows the input aimed at a nearby note). +- Damage interprets the mine object value (upper-case base36) **directly as a percentage** (LR2 / beatoraja behavior; this differs from the nanasi-lineage `value / 2` rule). A bmson mine with `key_channels[].notes[].damage` uses that value instead. +- Mine damage bypasses the HARD sub-30% softening and the `#TOTAL` damage multiplier (same as beatoraja's direct `gauge.addValue()`). +- `ZZ` (= 1295 %) instantly FAILs survival gauges (HARD / DEATH); GROOVE / EASY stop at the `2%` floor. +- kitty keyboard protocol input uses the real press/release state; release-less fallback input approximates "held" with the same short grace window the LN hold logic uses. ### Invisible Note Invisible notes are kept separate from the normal playing target. @@ -198,27 +202,20 @@ BMS determines the judgment range at the start of playback using the following p `100` is the standard value and has the same width as `NORMAL`. The player interprets `#DEFEXRANK` with `Number.parseFloat()` and only accepts values ​​​​that are finite and greater than `0`. -`#RANK` is treated as a beatoraja-compatible scaling table `[25, 50, 75, 100, 125]`. +`#RANK` maps onto the internal judgerank percent axis `[25, 50, 75, 100, 75]` (`VERY HARD` = 25 / `HARD` = 50 / `NORMAL` = 75 / `EASY` = 100 / `VERY EASY` treated as `NORMAL`). `metadata.rank` is interpreted by rounding down to an integer, and values ​​​​outside the range are invalidated and fallback to the default value. -- `#RANK 0`: `25%` (`VERY HARD`) -- `#RANK 1`: `50%` (`HARD`) -- `#RANK 2`: `75%` (`NORMAL`) -- `#RANK 3`: `100%` (`EASY`) -- `#RANK 4`: `125%` (`VERY EASY`) - ### BMS conversion formula -The actual decision width of BMS is calculated based on `NORMAL = 75%`. -For example, `#DEFEXRANK 120` is ``1.2` times the standard judgment width'' and is treated as `PGREAT=20.004ms`, `GREAT=39.996ms`, `GOOD=140.004ms`, `BAD=300ms`. - -The same expression handles the value resolved from `#RANK`. -For example, `#RANK 4` is `125 / 75` times, so `VERY EASY` is about `1.666...` times wider than `NORMAL`. +Judgment widths are derived by piecewise-linear interpolation of the measured table above over the judgerank percent anchors (25 / 50 / 75 / 100) — the same model as lr2oraja's `JudgeWindowRule.LR2`. +`#DEFEXRANK n` converts to the percent axis as `n × 75 / 100`. For example `#DEFEXRANK 120` is percent `90` and yields `PGREAT=19.8ms`, `GREAT=52ms`, `GOOD=112ms`, `BAD=200ms` (fixed). +Values beyond `EASY` (percent `100`) extrapolate along the final segment; only `PGREAT` / `GREAT` / `GOOD` scale, and no width ever exceeds the fixed `BAD` gate of `±200ms`. ### BMS dynamic judgment width change In BMS, you can use the `#xxxA0` channel and `#EXRANKxx` to change the judgment range during the performance. The player resolves the event value of the `A0` channel as a key in `#EXRANKxx`, reads the value with `Number.parseFloat()`, and uses it only if it is finite and greater than `0`. +The adopted value shares `#DEFEXRANK`'s unit — a percentage with `RANK 2 = 100` as the baseline — so `#EXRANKxx 100` restores exactly the `NORMAL` judgment width. If `#EXRANKxx` is undefined, an empty string, a non-number, or less than or equal to `0`, the event does not change the judgment width. If there are multiple `A0` events, they are applied in chronological order, and the value reached later becomes the subsequent judgment width. @@ -242,8 +239,8 @@ bmson determines the judgment width using the following priority order. 2. `metadata.rank` 3. Default value `100` -The standard value for bmson is `100%`. -Therefore, `judgeRank=100` is treated as the IIDX standard judgment width, `50` is treated as half, and `150` is treated as `1.5` times. +bmson's `judgeRank` shares the `100 = NORMAL` percentage unit with `#DEFEXRANK` and converts through the same LR2 anchor interpolation. +Therefore `judgeRank=100` is exactly `NORMAL` (`±18/±40/±100/±200ms`). In the current implementation, bmson does not have dynamic judgment width change equivalent to BMS's `#EXRANKxx`. @@ -313,12 +310,15 @@ When `POOR` occurs, do the following: If there is an input but no undecided notes inside the `BAD` window for that lane set, fire an LR2-compatible "empty POOR" (空POOR). +- The trigger condition is that **a note on the same lane lies within the next 1 second** of the press (lr2oraja's LR2 miss window `{0, 1000000}`µs — fixed, independent of rank / EXRANK). Empty POORs never fire after a note passes (late side), and a press on a lane with no note within a second is harmless: only the keysound plays. +- It fires **repeatedly** for the same note while mashing in front of it (LR2's `MissCondition.ALWAYS`, including in front of already-judged notes). + Empty POOR mirrors LR2's phantom-press behaviour: - Do NOT update `summary` judge counters (`perfect` / `great` / `good` / `bad` / `poor`). LR2 only counts the "missed POOR" branch (NOWJUDGE index 1) into the POOR tally; the "empty POOR" branch (index 0) is excluded. - Do NOT change EX-SCORE / IIDX score. - Do NOT cut the combo. -- Apply `EMPTY_POOR` to the groove gauge — the delta lives in [`groove-gauge.ts`](../packages/player/src/core/groove-gauge.ts) (`applyGrooveGaugeJudge('EMPTY_POOR')`): GROOVE / HARD `-2`, EASY `-1`, DEATH `-100` (instant 0%). Nearly harmless on NORMAL / EASY; meaningful drain on HARD / DEATH. +- Apply `EMPTY_POOR` to the groove gauge — the delta lives in [`groove-gauge.ts`](../packages/player/src/core/groove-gauge.ts) (`applyGrooveGaugeJudge('EMPTY_POOR')`): GROOVE `-2`, HARD `-2` (subject to the TOTAL multiplier), EASY `-1.6`, DEATH `-10`. Nearly harmless on NORMAL / EASY; meaningful drain on HARD / DEATH. - Trigger POOR BGA (`trigger-poor-bga`). - Flash the judge display as `POOR` for 0.6 s (`publishJudgeCombo('POOR', combo)`). The LR2 spec separates op 246 (1P empty POOR) / 266 (2P empty POOR) from op 245 / 265 (missed POOR), but both NOWJUDGE indices currently resolve to the same `'poor'` skin slot in this implementation, so the rendered sprite is identical. @@ -422,6 +422,13 @@ The following deltas describe the default `GROOVE` gauge. `HARD`, `DEATH`, and ` The value after gauge update is clamped to the current gauge type's min/max range. +The `HARD` / `EASY` / `DEATH` variants follow the LR2 values (beatoraja `GaugeProperty`'s `HARD_LR2` / `EASY_LR2` / `HAZARD_LR2`). + +- `HARD`: recovery `PGREAT/GREAT +0.1` / `GOOD +0.05` (TOTAL-independent); damage `BAD -6` / `missed POOR -10` / `empty POOR -2`. Damage is multiplied by the `#TOTAL` table (`×1.0` at `TOTAL ≥ 240` up to `×10` below `120`) and softened by `×0.6` while the gauge is under `30%`. +- `EASY`: gains are `1.2×` GROOVE, damage is `0.8×` GROOVE (`BAD -3.2` / `POOR -4.8` / `empty POOR -1.6`). The clear threshold stays at `80%`, same as GROOVE. +- `DEATH` (LR2 HAZARD equivalent): `PGREAT +0.15` / `GREAT +0.06` / `GOOD 0`; `BAD` / missed `POOR` are `-100` (instant death); `empty POOR -10`. +- `HARD` / `DEATH` collapse to `0%` (FAILED, no recovery) the moment they drop below `2%`. Raw deltas such as mine damage bypass the guts softening and the TOTAL multiplier. + ## Long Note ### How to count NOTES @@ -433,7 +440,8 @@ Long notes derived from `#mmm51-69` are also counted as one starting note. ### `#LNMODE` If `#LNMODE` of BMS is not specified, it is treated as `1`. -bmson and FREE ZONE are not subject to `#LNMODE` and are treated as notes with a terminal. +bmson resolves the mode via the beatoraja extensions `info.ln_type` and the per-note `t` (1: LN / 2: CN / 3: HCN, `t` takes precedence over `ln_type`); when neither is specified the LR2-aligned default `1` (LN) is used. +FREE ZONE is not subject to `#LNMODE` and is treated as a note with a terminal. ### Manual Play @@ -616,7 +624,7 @@ A playback progress indicator is displayed outside the lane, and the line closes Even on judged notes, drawing will remain until the judge line is crossed or the `visibleUntilBeat` expires. A long note is drawn as a single note with a body and tail lane, and the highlight continues while being held. -The visual distance of a note is determined by the integral of the piecewise-constant coefficient of `#SCROLLxx` / `#xxxSC` multiplied by the piecewise-linear interpolation coefficient of `#SPEEDxx` / `#xxxSP`. If there is no `#SPEEDxx`, it is always `1`, and multiple keyframes with the same beat are the last to win. If the value of `#SPEEDxx` is a negative number, a non-number, or an undefined reference, that keyframe will be ignored from drawing calculations. +The visual distance of a note is determined by the integral of the piecewise-constant coefficient of `#SCROLLxx` / `#xxxSC` multiplied by the piecewise-linear interpolation coefficient of `#SPEEDxx` / `#xxxSP`. If there is no `#SPEEDxx`, it is always `1`, and multiple keyframes with the same beat are the last to win. Before the first keyframe, the first keyframe's value holds flat (matching the Bemuse reference implementation). If the value of `#SPEEDxx` is a negative number, a non-number, or an undefined reference, that keyframe will be ignored from drawing calculations. ### Non-TUI output diff --git a/docs/player-web.ja.md b/docs/player-web.ja.md index 32da08f4..05be7fef 100644 --- a/docs/player-web.ja.md +++ b/docs/player-web.ja.md @@ -81,6 +81,8 @@ select scene は、hi-speed、autoplay、BGA mode/size、filter、sort、HS-FIX select 時の option は chart preparation 時に gameplay へ引き継ぎます。 現在の shared-engine path は引き続き `GROOVE` gauge summary を publish します。LR2 gauge button は主に skin op state と scene-local setup を駆動し、2P independent gauge math はまだ接続していません。 +グルーヴゲージのバーは LR2 の 50 ビード(各 2 %)として、単一の `#SRC_GROOVEGAUGE` の 4 セルスプライト(`表赤/表緑/裏赤/裏緑` = lit-red, lit-green, unlit-red, unlit-green)で描画します。80 % のクリア境界以上のビードは緑セル、未満は赤セルを使います。survival 系ゲージ(HARD / DEATH)は LR2 ではクリア境界が無いため、バー全体を赤で描画します。バーは現在のゲージ値のみを追従し、peak-hold(残像)ビードは描きません(LR2 にも無い)。ビードのセル選択ロジックは `scene/lr2/gameplay-hud.ts` の純関数 `resolveGrooveGaugeBeads` です。 + scene に依存しない LR2 Pixi helper は [`skin/lr2/render.ts`](../packages/player-web/src/skin/lr2/render.ts) と [`skin/lr2/scene-render.ts`](../packages/player-web/src/skin/lr2/scene-render.ts) にあります。destination keyframe 評価、sprite transform、source cell 選択、text rendering、number、slider、bargraph を共通化します。scene module は state 固有の値解決、timer、input behavior だけを保持します。 diff --git a/docs/player-web.md b/docs/player-web.md index c6930a37..34ba00cf 100644 --- a/docs/player-web.md +++ b/docs/player-web.md @@ -82,6 +82,8 @@ The select scene exposes in-scene LR2 PLAY OPTION controls for hi-speed, autopla Select-time options are carried into gameplay during chart preparation. The current shared-engine path still publishes a `GROOVE` gauge summary; LR2 gauge buttons mainly drive skin op state and the scene-local setup, and independent 2P gauge math is not wired yet. +The groove-gauge bar renders as LR2's 50 beads (2 % each) using the single `#SRC_GROOVEGAUGE` 4-cell sprite (`表赤/表緑/裏赤/裏緑` = lit-red, lit-green, unlit-red, unlit-green). Beads at or above the 80 % clear border use the green cell; below it they use red. Survival gauges (HARD / DEATH) have no clear border in LR2, so the whole bar renders red. The bar tracks the live gauge value only — there is no peak-hold / afterimage bead (LR2 has none). The bead cell-selection logic is the pure `resolveGrooveGaugeBeads` helper in `scene/lr2/gameplay-hud.ts`. + Scene-independent LR2 Pixi helpers live in [`skin/lr2/render.ts`](../packages/player-web/src/skin/lr2/render.ts) and [`skin/lr2/scene-render.ts`](../packages/player-web/src/skin/lr2/scene-render.ts). They handle destination keyframe evaluation, sprite transforms, source-cell selection, text rendering, numbers, sliders, and bargraphs. Scene modules keep the diff --git a/package.json b/package.json index 7857bafe..990c06ff 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,10 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260607.1", + "@typescript/native-preview": "7.0.0-dev.20260611.2", "@vitest/coverage-v8": "^4.0.18", - "oxfmt": "^0.52.0", - "oxlint": "^1.67.0", + "oxfmt": "^0.54.0", + "oxlint": "^1.69.0", "rimraf": "^6.0.1", "tinybench": "^6.0.2", "tsdown": "^0.22.2", diff --git a/packages/audio-renderer/CHANGELOG.md b/packages/audio-renderer/CHANGELOG.md index 20a81036..eae55c78 100644 --- a/packages/audio-renderer/CHANGELOG.md +++ b/packages/audio-renderer/CHANGELOG.md @@ -1,5 +1,20 @@ # @be-music/audio-renderer +## 0.2.3 + +### Patch Changes + +- 6ce9173: Missing, undefined, or undecodable `#WAVxx` references are now silent by default, matching LR2 / beatoraja. The synthesized sine fallback tone is opt-in: pass `fallbackToneSeconds` to the audio-renderer APIs or `missingSampleToneSeconds` to the player engine when a debugging tone is wanted. +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.2.2 ### Patch Changes diff --git a/packages/audio-renderer/package.json b/packages/audio-renderer/package.json index fdfbb726..12f69479 100644 --- a/packages/audio-renderer/package.json +++ b/packages/audio-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/audio-renderer", - "version": "0.2.2", + "version": "0.2.3", "description": "Renderer for BMS, BMSON, and be-music JSON charts to WAV or AIFF", "license": "MIT", "bin": { diff --git a/packages/audio-renderer/src/core/engine.ts b/packages/audio-renderer/src/core/engine.ts index 50520fe0..bbd7053b 100644 --- a/packages/audio-renderer/src/core/engine.ts +++ b/packages/audio-renderer/src/core/engine.ts @@ -6,6 +6,7 @@ import { isBmsKeyVolumeChangeChannel, isPlayLaneSoundChannel, parseBmsDynamicVolumeGain, + usesMonophonicWavPlayback, } from '@be-music/chart'; // `node:fs/promises` and `node:path` are loaded lazily inside the Node-only entry points (`renderChartFile`, // `writeAudioFile`, `getOrCreateSample`) so that bundling this module into a browser build doesn't statically @@ -176,7 +177,10 @@ export async function renderJson(json: BeMusicJson, options: RenderOptions = {}) const tailSeconds = options.tailSeconds ?? DEFAULT_TAIL_SECONDS; const gain = (options.gain ?? DEFAULT_GAIN) * resolveChartVolWavGain(json); const startSeconds = Number.isFinite(options.startSeconds) ? Math.max(0, options.startSeconds ?? 0) : 0; - const fallbackToneSeconds = options.fallbackToneSeconds ?? 0.08; + // BMS spec — an undefined / missing / undecodable `#WAVxx` reference is SILENT (LR2 / beatoraja both skip it). + // `fallbackToneSeconds` exists as an opt-in debug aid; 0 renders a 1-frame silent placeholder so every downstream + // path (scheduling, gain, progress reporting) still sees a sample. + const fallbackToneSeconds = options.fallbackToneSeconds ?? 0; const baseDir = options.baseDir ?? process.cwd(); const signal = options.signal; const resolveTriggerGain = options.resolveTriggerGain; @@ -336,7 +340,7 @@ async function scheduleSampleRenders(params: { sampleOffsetFrames: frameWindow.sampleOffsetFrames, sampleMaxFrames: frameWindow.sampleMaxFrames, }) - 1; - if (json.sourceFormat === 'bms') { + if (usesMonophonicWavPlayback(json)) { latestBmsScheduleBySampleKey.set(trigger.sampleKey, scheduleIndex); } if (triggerGain > 0) { @@ -425,7 +429,7 @@ function trimPreviousBmsRetrigger( scheduled: ScheduledSampleRender[], latestBmsScheduleBySampleKey: Map, ): void { - if (json.sourceFormat !== 'bms') { + if (!usesMonophonicWavPlayback(json)) { return; } const previousIndex = latestBmsScheduleBySampleKey.get(trigger.sampleKey); @@ -476,7 +480,9 @@ export async function renderSingleSample( const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE; const gain = typeof options.gain === 'number' && Number.isFinite(options.gain) ? options.gain : 1; const baseDir = options.baseDir ?? process.cwd(); - const fallbackToneSeconds = options.fallbackToneSeconds ?? 0.08; + // Spec-compliant default: missing / undecodable samples are silent. Pass a positive value to opt into the + // debugging sine tone. + const fallbackToneSeconds = options.fallbackToneSeconds ?? 0; throwIfAborted(options.signal); const sample = await getOrCreateSample({ sampleKey: normalizedKey, diff --git a/packages/beatoraja-skin/CHANGELOG.md b/packages/beatoraja-skin/CHANGELOG.md index dcc01bf2..e645d714 100644 --- a/packages/beatoraja-skin/CHANGELOG.md +++ b/packages/beatoraja-skin/CHANGELOG.md @@ -1,5 +1,12 @@ # @be-music/beatoraja-skin +## 0.1.2 + +### Patch Changes + +- Updated dependencies [ca1012c] + - @be-music/utils@0.3.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/beatoraja-skin/package.json b/packages/beatoraja-skin/package.json index d4bb714e..f138feb8 100644 --- a/packages/beatoraja-skin/package.json +++ b/packages/beatoraja-skin/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/beatoraja-skin", - "version": "0.1.1", + "version": "0.1.2", "description": "Renderer-independent beatoraja JSON / Lua skin parser and theme loader for be-music", "license": "MIT", "files": [ diff --git a/packages/chart/CHANGELOG.md b/packages/chart/CHANGELOG.md index 1f07737b..7c6b071a 100644 --- a/packages/chart/CHANGELOG.md +++ b/packages/chart/CHANGELOG.md @@ -1,5 +1,12 @@ # @be-music/chart +## 0.3.2 + +### Patch Changes + +- Updated dependencies [b9922cf] + - @be-music/json@0.2.2 + ## 0.3.1 ### Patch Changes diff --git a/packages/chart/package.json b/packages/chart/package.json index 82e9a4d7..678245ed 100644 --- a/packages/chart/package.json +++ b/packages/chart/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/chart", - "version": "0.3.1", + "version": "0.3.2", "description": "Chart semantics helpers for the be-music intermediate representation", "license": "MIT", "files": [ diff --git a/packages/chart/scripts/exports-cases.ts b/packages/chart/scripts/exports-cases.ts index 647886cd..4e94b06b 100644 --- a/packages/chart/scripts/exports-cases.ts +++ b/packages/chart/scripts/exports-cases.ts @@ -183,6 +183,11 @@ export function registerChartExportsCases(define: DefineBenchmarkCase): void { chartApi.parseBmsDynamicVolumeGain('80'); }, }); + define('chart.usesMonophonicWavPlayback', { + run: (fixtures) => { + chartApi.usesMonophonicWavPlayback(fixtures.sampleBmsJson); + }, + }); define('chart.resolveBmsLongNotes', { run: (fixtures) => { chartApi.resolveBmsLongNotes(fixtures.sampleBmsJson, { inferLnTypeWhenMissing: true }); diff --git a/packages/chart/src/index.test.ts b/packages/chart/src/index.test.ts index 9be2ab22..64bbe63f 100644 --- a/packages/chart/src/index.test.ts +++ b/packages/chart/src/index.test.ts @@ -36,6 +36,7 @@ import { resolveBmsLongNotes, resolveLnobjLongNotes, sortEvents, + usesMonophonicWavPlayback, } from './index.ts'; import { createEmptyJson, type BeMusicEvent } from '../../json/src/index.ts'; @@ -549,6 +550,12 @@ describe('chart', () => { expect(parseBmsDynamicVolumeGain('GG')).toBeUndefined(); }); + test('usesMonophonicWavPlayback is true only for BMS-sourced charts', () => { + expect(usesMonophonicWavPlayback({ sourceFormat: 'bms' })).toBe(true); + expect(usesMonophonicWavPlayback({ sourceFormat: 'bmson' })).toBe(false); + expect(usesMonophonicWavPlayback({ sourceFormat: 'json' })).toBe(false); + }); + test('classifies normalized fallback inputs after trimming and case normalization', () => { expect(isScrollChannel(' sc ')).toBe(true); expect(isStopChannel(' 09 ')).toBe(true); diff --git a/packages/chart/src/index.ts b/packages/chart/src/index.ts index 3ed21eb4..7ff7cf59 100644 --- a/packages/chart/src/index.ts +++ b/packages/chart/src/index.ts @@ -904,6 +904,18 @@ export function isBmsDynamicVolumeChangeChannel(channel: string): boolean { ); } +/** + * Whether a chart's keysounds restart from the head when the same `#WAVxx` slot is retriggered (one voice per slot). + * + * BMS uses the LR2-compatible monophonic policy: a retrigger stops the still-playing instance of that slot and plays + * it again from the start. bmson layers sample slices / `c: true` continuation instead, so its slots are polyphonic. + * Every audio backend (Node mixer, WebAudio session, offline renderer) must consult this single predicate rather than + * encoding the per-format rule locally. + */ +export function usesMonophonicWavPlayback(json: Pick): boolean { + return json.sourceFormat === 'bms'; +} + export function parseBmsDynamicVolumeGain(value: string): number | undefined { const normalized = normalizeObjectKey(value); const high = parseHexDigitFast(normalized.charCodeAt(0)); diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index eae29684..d405ac4c 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -1,5 +1,20 @@ # @be-music/editor +## 0.2.3 + +### Patch Changes + +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/stringifier@0.3.1 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.2.2 ### Patch Changes diff --git a/packages/editor/package.json b/packages/editor/package.json index 389bac9c..2afbccab 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/editor", - "version": "0.2.2", + "version": "0.2.3", "description": "CLI editor for importing, editing, and exporting BMS charts", "license": "MIT", "bin": { diff --git a/packages/json/CHANGELOG.md b/packages/json/CHANGELOG.md index 422ad8b9..b426a19c 100644 --- a/packages/json/CHANGELOG.md +++ b/packages/json/CHANGELOG.md @@ -1,5 +1,13 @@ # @be-music/json +## 0.2.2 + +### Patch Changes + +- b9922cf: Support the beatoraja bmson long-note type extensions: `info.ln_type` and per-note `t` (1: LN, 2: CN, 3: HCN) are preserved in the IR, round-trip through JSON and bmson output, and drive the player's long-note mode (per-note `t` wins over `info.ln_type`). Charts that specify neither now default to LN (no tail release judgment, matching the LR2-aligned BMS default) instead of always being treated as CN. +- Updated dependencies [ca1012c] + - @be-music/utils@0.3.0 + ## 0.2.1 ### Patch Changes diff --git a/packages/json/package.json b/packages/json/package.json index f70ba810..70aa5d4e 100644 --- a/packages/json/package.json +++ b/packages/json/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/json", - "version": "0.2.1", + "version": "0.2.2", "description": "Internal BMS/BMSON intermediate representation for be-music", "license": "MIT", "files": [ diff --git a/packages/json/src/index.ts b/packages/json/src/index.ts index bd3e1a1b..ceb5ef03 100644 --- a/packages/json/src/index.ts +++ b/packages/json/src/index.ts @@ -44,6 +44,11 @@ export type BeMusicPosition = readonly [numerator: number, denominator: number]; export interface BmsonEventExtensions { l?: number; c?: boolean; + /** + * Per-note long-note type (beatoraja bmson extension `notes[].t`): `1` = LN, `2` = CN, `3` = HCN. Only meaningful + * on long notes (`l > 0`) and takes precedence over the chart-level `info.ln_type`. + */ + t?: number; /** * Per-mine gauge damage in 0..100, sourced from a bmson `key_channels[].notes[].damage` entry. Only set on mine * events emitted from `key_channels`; absent on regular `sound_channels`-derived note events. Consumers that don't @@ -78,6 +83,11 @@ export interface BmsonInfoExtensions { modeHint?: string; judgeRank?: number; total?: number; + /** + * Chart-level long-note type (beatoraja bmson extension `info.ln_type`): `1` = LN, `2` = CN, `3` = HCN. A per-note + * `t` overrides this; unspecified charts follow the player's default LN mode. + */ + lnType?: number; backImage?: string; eyecatchImage?: string; bannerImage?: string; @@ -116,6 +126,8 @@ export interface BmsonSoundNoteEntry { y: number; l?: number; c?: boolean; + /** beatoraja bmson extension — per-note long-note type (`1` = LN, `2` = CN, `3` = HCN) for notes with `l > 0`. */ + t?: number; } export interface BmsonSoundChannelEntry { diff --git a/packages/lr2-skin/CHANGELOG.md b/packages/lr2-skin/CHANGELOG.md index e7550561..7969d781 100644 --- a/packages/lr2-skin/CHANGELOG.md +++ b/packages/lr2-skin/CHANGELOG.md @@ -1,5 +1,15 @@ # @be-music/lr2-skin +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b9922cf] +- Updated dependencies [ca1012c] + - @be-music/json@0.2.2 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.1.3 ### Patch Changes diff --git a/packages/lr2-skin/package.json b/packages/lr2-skin/package.json index 04eadc37..1722a4f9 100644 --- a/packages/lr2-skin/package.json +++ b/packages/lr2-skin/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/lr2-skin", - "version": "0.1.3", + "version": "0.1.4", "description": "Renderer-independent Lunatic Rave 2 skin parser and theme loader for be-music", "license": "MIT", "files": [ diff --git a/packages/parser/CHANGELOG.md b/packages/parser/CHANGELOG.md index 24891592..964551ef 100644 --- a/packages/parser/CHANGELOG.md +++ b/packages/parser/CHANGELOG.md @@ -1,5 +1,19 @@ # @be-music/parser +## 0.2.3 + +### Patch Changes + +- b2c4f9b: `decodeBmsText` now implements the full documented encoding-detection pipeline: UTF-16LE / UTF-16BE BOMs are recognized (such charts previously decoded as garbled shift_jis and lost every event), BOM-less files that strictly validate as UTF-8 decode as UTF-8, and remaining files are scored across shift_jis / utf-8 / euc-jp / iso-8859-1 instead of unconditionally falling back to shift_jis. +- b9922cf: Support the beatoraja bmson long-note type extensions: `info.ln_type` and per-note `t` (1: LN, 2: CN, 3: HCN) are preserved in the IR, round-trip through JSON and bmson output, and drive the player's long-note mode (per-note `t` wins over `info.ln_type`). Charts that specify neither now default to LN (no tail release judgment, matching the LR2-aligned BMS default) instead of always being treated as CN. +- 4d5a89e: Accept the real-world control-flow spelling variants `#END IF`, bare `#END`, and `#ELSE IF n` as `#ENDIF` / `#ELSEIF n`. Previously such charts left the `#IF` block unterminated, so a non-matching `#RANDOM` roll silently dropped every line after the misspelled directive. +- cdc42a1: BMS object data lines (`#mmmcc:data`) are now truncated at the first whitespace character. Trailing text no longer fabricates note events (e.g. `junk` becoming `JU`/`NK` objects) or inflates the position denominator of the legitimate tokens before it. +- Updated dependencies [b9922cf] +- Updated dependencies [ca1012c] + - @be-music/json@0.2.2 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.2.2 ### Patch Changes diff --git a/packages/parser/package.json b/packages/parser/package.json index b21233e9..0e67fffc 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/parser", - "version": "0.2.2", + "version": "0.2.3", "description": "Parser for BMS, BMSON, and be-music JSON charts", "license": "MIT", "bin": { @@ -34,7 +34,6 @@ "dependencies": { "@be-music/chart": "workspace:*", "@be-music/json": "workspace:*", - "@be-music/utils": "workspace:*", - "iconv-lite": "^0.7.0" + "@be-music/utils": "workspace:*" } } diff --git a/packages/parser/scripts/exports-cases.ts b/packages/parser/scripts/exports-cases.ts index e1216116..1672fe23 100644 --- a/packages/parser/scripts/exports-cases.ts +++ b/packages/parser/scripts/exports-cases.ts @@ -13,6 +13,11 @@ export function registerParserExportsCases(define: DefineBenchmarkCase): void { parserApi.decodeBmsText(fixtures.bmsBuffer); }, }); + define('parser.decodeUtf8Text', { + run: (fixtures) => { + parserApi.decodeUtf8Text(fixtures.bmsBuffer); + }, + }); define('parser.extractDeclaredBmsCharset', { run: () => { parserApi.extractDeclaredBmsCharset('#CHARSET Shift_JIS\n#TITLE Bench\n'); diff --git a/packages/parser/src/core/bms-text-decode.ts b/packages/parser/src/core/bms-text-decode.ts new file mode 100644 index 00000000..b8c2d34b --- /dev/null +++ b/packages/parser/src/core/bms-text-decode.ts @@ -0,0 +1,282 @@ +/** + * Canonical BMS text decoding pipeline. + * + * This module is THE single implementation of "bytes → BMS source text" for every runtime (CLI / TUI via + * `parseChartFile`, the web player's collection loader, and any external consumer of `@be-music/parser`). Keep all + * encoding-detection behavior here — duplicating this flow per runtime is how charts end up decoding differently + * between the TUI and the browser. + * + * Pipeline (docs/bms-spec.md「文字コード」): + * + * 1. BOM — UTF-8 / UTF-16LE / UTF-16BE BOMs are authoritative. + * 2. `#CHARSET ` directive — scanned via a latin1 first-pass (byte-transparent), decoded with the declared + * encoding when it canonicalizes to a supported label. + * 3. ASCII-only buffers decode as UTF-8 (identical for every candidate, so skip the scoring walk). + * 4. Strict UTF-8 — a buffer that validates as UTF-8 with multibyte sequences present is UTF-8; real Shift_JIS / + * EUC-JP text is statistically never valid multibyte UTF-8. + * 5. Scoring — decode with shift_jis / utf-8 / euc-jp / iso-8859-1 candidates and pick the highest-scoring text + * (replacement characters and control bytes penalize, BMS-shaped lines and Japanese text reward). + * + * Built exclusively on the WHATWG `TextDecoder` so the same code runs in Node and the browser. + */ +import { extractDeclaredBmsCharset } from './bms-charset.ts'; + +const OBJECT_DATA_LINE = /^#(\d{3})([0-9A-Z]{2})\s*:\s*(.+)\s*$/i; +const HEADER_LINE = /^#([A-Z][A-Z0-9_]*)(?:\s+(.+))?$/i; +const BMS_KNOWN_COMMAND_LINE = + /^#(?:TITLE|SUBTITLE|ARTIST|GENRE|COMMENT|BPM|PLAYLEVEL|RANK|TOTAL|DIFFICULTY|STAGEFILE|BACKBMP|BANNER|PREVIEW|LNTYPE|LNMODE|LNOBJ|VOLWAV|DEFEXRANK|PLAYER|PATH_WAV|BASEBPM|STP|OPTION|WAVCMD|POORBGA|VIDEOFILE|MIDIFILE|MATERIALS|DIVIDEPROP|CHARSET|WAV[0-9A-Z]{2}|BMP[0-9A-Z]{2}|BPM[0-9A-Z]{2}|STOP[0-9A-Z]{2}|TEXT[0-9A-Z]{2}|EXRANK[0-9A-Z]{2}|ARGB[0-9A-Z]{2}|CHANGEOPTION[0-9A-Z]{2}|EXWAV[0-9A-Z]{2}|EXBMP[0-9A-Z]{2}|BGA[0-9A-Z]{2}|SCROLL[0-9A-Z]{2}|SPEED[0-9A-Z]{2}|SWBGA[0-9A-Z]{2}|RANDOM\s+\d+|SETRANDOM\s+\d+|ENDRANDOM|IF\s+\d+|ELSEIF\s+\d+|ELSE|ENDIF|SWITCH\s+\d+|SETSWITCH\s+\d+|CASE\s+\d+|DEF|SKIP|ENDSW|[0-9]{3}[0-9A-Z]{2}\s*:)/i; + +export interface DecodedBmsText { + encoding: 'utf8' | 'shift_jis' | 'euc-jp' | 'utf-16le' | 'utf-16be' | 'iso-8859-1'; + text: string; +} + +export function decodeBmsText(buffer: Uint8Array): DecodedBmsText { + if (hasUtf8Bom(buffer)) { + return { + encoding: 'utf8', + text: decodeUtf8Text(buffer), + }; + } + if (hasUtf16LeBom(buffer)) { + return { + encoding: 'utf-16le', + text: new TextDecoder('utf-16le').decode(buffer).replace(/^\ufeff/u, ''), + }; + } + if (hasUtf16BeBom(buffer)) { + return { + encoding: 'utf-16be', + text: new TextDecoder('utf-16be').decode(buffer).replace(/^\ufeff/u, ''), + }; + } + // BMS spec — honor `#CHARSET ` at the top of the file before any automatic detection. The directive is + // authored before any non-ASCII text, so a latin1 first-pass (every byte → its 0..255 code point) always surfaces + // it. The web `TextDecoder` accepts the same canonical encoding names `canonicalizeBmsCharset` produces + // (utf-8 / shift_jis / euc-jp / utf-16le / utf-16be / iso-8859-1), so we can route directly through it without an + // intermediate library. + const declaredCharset = extractDeclaredBmsCharset(decodeLatin1Text(buffer)); + if (declaredCharset) { + const decoded = decodeWithDeclaredCharset(buffer, declaredCharset); + if (decoded) return decoded; + } + if (isAsciiBuffer(buffer)) { + return { + encoding: 'utf8', + text: decodeUtf8Text(buffer), + }; + } + // A buffer that strictly validates as UTF-8 while containing multibyte sequences is UTF-8: legacy Japanese + // encodings essentially never produce byte streams that survive a fatal UTF-8 decode. This catches the common + // modern case (BOM-less UTF-8 charts) that the relative-scoring walk below can misattribute to shift_jis when the + // non-ASCII payload is short. + const strictUtf8 = tryDecodeStrictUtf8(buffer); + if (strictUtf8 !== undefined) { + return { + encoding: 'utf8', + text: strictUtf8, + }; + } + return decodeByScoring(buffer); +} + +function decodeLatin1Text(buffer: Uint8Array): string { + // `iso-8859-1` is required by the WHATWG Encoding spec, so every browser and Node ships it. Maps every byte 1:1 to a + // Unicode code point in [0, 255], which lets the `#CHARSET` scan look at the file's bytes without misinterpretation + // regardless of the actual encoding. + return new TextDecoder('iso-8859-1').decode(buffer); +} + +function decodeWithDeclaredCharset(buffer: Uint8Array, charset: string): DecodedBmsText | undefined { + // BMS `#CHARSET` declares the encoding for the file. We map the canonicalized name onto a `TextDecoder` label and + // strip a leading BOM where applicable. `TextDecoder` throws synchronously for unrecognized labels, which we treat as + // "fall back to autodetection" — same as a value that didn't canonicalize. + try { + switch (charset) { + case 'utf-8': + return { encoding: 'utf8', text: decodeUtf8Text(buffer) }; + case 'shift_jis': + return { encoding: 'shift_jis', text: new TextDecoder('shift_jis').decode(buffer) }; + case 'euc-jp': + return { encoding: 'euc-jp', text: new TextDecoder('euc-jp').decode(buffer) }; + case 'utf-16le': + return { encoding: 'utf-16le', text: new TextDecoder('utf-16le').decode(buffer).replace(/^\ufeff/u, '') }; + case 'utf-16be': + return { encoding: 'utf-16be', text: new TextDecoder('utf-16be').decode(buffer).replace(/^\ufeff/u, '') }; + case 'iso-8859-1': + return { encoding: 'iso-8859-1', text: new TextDecoder('iso-8859-1').decode(buffer) }; + default: + return undefined; + } + } catch { + return undefined; + } +} + +function decodeByScoring(buffer: Uint8Array): DecodedBmsText { + const candidates: Array<{ encoding: DecodedBmsText['encoding']; label: string; bias: number }> = [ + { encoding: 'shift_jis', label: 'shift_jis', bias: 5 }, + { encoding: 'utf8', label: 'utf-8', bias: 4 }, + { encoding: 'euc-jp', label: 'euc-jp', bias: 3 }, + { encoding: 'iso-8859-1', label: 'iso-8859-1', bias: -5 }, + ]; + + let best: DecodedBmsText | undefined; + let bestScore = Number.NEGATIVE_INFINITY; + for (const candidate of candidates) { + let text: string; + try { + text = new TextDecoder(candidate.label).decode(buffer); + } catch { + continue; + } + const score = scoreDecodedBmsText(text, candidate.bias); + if (score > bestScore) { + bestScore = score; + best = { + encoding: candidate.encoding, + text, + }; + } + } + + return ( + best ?? { + encoding: 'utf8', + text: decodeUtf8Text(buffer), + } + ); +} + +function scoreDecodedBmsText(text: string, bias: number): number { + let score = bias; + if (text.length === 0) { + return Number.NEGATIVE_INFINITY; + } + + const textStats = collectTextStatistics(text); + score -= textStats.replacementCount * 120; + score -= textStats.nullCount * 80; + score -= textStats.lowControlCount * 8; + + const lines = text.split(/\r?\n/); + let hashLines = 0; + let objectLines = 0; + let headerLines = 0; + let knownCommandLines = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('#')) { + continue; + } + + hashLines += 1; + if (OBJECT_DATA_LINE.test(trimmed)) { + objectLines += 1; + } else if (HEADER_LINE.test(trimmed)) { + headerLines += 1; + } + if (BMS_KNOWN_COMMAND_LINE.test(trimmed)) { + knownCommandLines += 1; + } + } + + score += hashLines * 0.4; + score += objectLines * 14; + score += headerLines * 8; + score += knownCommandLines * 3; + + const printableRatio = textStats.printableCount / Math.max(1, text.length); + score += printableRatio * 20; + + score += Math.min(40, textStats.japaneseCount * 0.02); + + return score; +} + +function collectTextStatistics(text: string): { + replacementCount: number; + nullCount: number; + lowControlCount: number; + printableCount: number; + japaneseCount: number; +} { + let replacementCount = 0; + let nullCount = 0; + let lowControlCount = 0; + let printableCount = 0; + let japaneseCount = 0; + + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code === 0xfffd) { + replacementCount += 1; + } + if (code === 0x0000) { + nullCount += 1; + } + if ( + (code >= 0x0001 && code <= 0x0008) || + (code >= 0x000b && code <= 0x000c) || + (code >= 0x000e && code <= 0x001f) + ) { + lowControlCount += 1; + } + if ( + code === 0x000a || + code === 0x000d || + code === 0x0009 || + (code >= 0x0020 && code <= 0x007e) || + (code >= 0x00a0 && code <= 0x00ff) || + (code >= 0x3000 && code <= 0x30ff) || + (code >= 0x3400 && code <= 0x9fff) + ) { + printableCount += 1; + } + if ((code >= 0x3040 && code <= 0x30ff) || (code >= 0x3400 && code <= 0x9fff)) { + japaneseCount += 1; + } + } + + return { + replacementCount, + nullCount, + lowControlCount, + printableCount, + japaneseCount, + }; +} + +function tryDecodeStrictUtf8(buffer: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch { + return undefined; + } +} + +export function decodeUtf8Text(buffer: Uint8Array): string { + return new TextDecoder('utf-8').decode(buffer).replace(/^\ufeff/u, ''); +} + +function hasUtf8Bom(buffer: Uint8Array): boolean { + return buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf; +} + +function hasUtf16LeBom(buffer: Uint8Array): boolean { + return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; +} + +function hasUtf16BeBom(buffer: Uint8Array): boolean { + return buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; +} + +function isAsciiBuffer(buffer: Uint8Array): boolean { + for (let index = 0; index < buffer.length; index += 1) { + if (buffer[index]! >= 0x80) { + return false; + } + } + return true; +} diff --git a/packages/parser/src/core/bms-text-decoder.ts b/packages/parser/src/core/bms-text-decoder.ts deleted file mode 100644 index 20461ca0..00000000 --- a/packages/parser/src/core/bms-text-decoder.ts +++ /dev/null @@ -1,255 +0,0 @@ -import iconv from 'iconv-lite'; -import { extractDeclaredBmsCharset } from './bms-charset.ts'; - -const OBJECT_DATA_LINE = /^#(\d{3})([0-9A-Z]{2})\s*:\s*(.+)\s*$/i; -const HEADER_LINE = /^#([A-Z][A-Z0-9_]*)(?:\s+(.+))?$/i; -const BMS_KNOWN_COMMAND_LINE = - /^#(?:TITLE|SUBTITLE|ARTIST|GENRE|COMMENT|BPM|PLAYLEVEL|RANK|TOTAL|DIFFICULTY|STAGEFILE|BACKBMP|BANNER|PREVIEW|LNTYPE|LNMODE|LNOBJ|VOLWAV|DEFEXRANK|PLAYER|PATH_WAV|BASEBPM|STP|OPTION|WAVCMD|POORBGA|VIDEOFILE|MIDIFILE|MATERIALS|DIVIDEPROP|CHARSET|WAV[0-9A-Z]{2}|BMP[0-9A-Z]{2}|BPM[0-9A-Z]{2}|STOP[0-9A-Z]{2}|TEXT[0-9A-Z]{2}|EXRANK[0-9A-Z]{2}|ARGB[0-9A-Z]{2}|CHANGEOPTION[0-9A-Z]{2}|EXWAV[0-9A-Z]{2}|EXBMP[0-9A-Z]{2}|BGA[0-9A-Z]{2}|SCROLL[0-9A-Z]{2}|SPEED[0-9A-Z]{2}|SWBGA[0-9A-Z]{2}|RANDOM\s+\d+|SETRANDOM\s+\d+|ENDRANDOM|IF\s+\d+|ELSEIF\s+\d+|ELSE|ENDIF|SWITCH\s+\d+|SETSWITCH\s+\d+|CASE\s+\d+|DEF|SKIP|ENDSW|[0-9]{3}[0-9A-Z]{2}\s*:)/i; - -type DetectedBmsEncoding = 'utf8' | 'shift_jis' | 'euc-jp' | 'latin1' | 'utf16le' | 'utf16be'; - -export interface DecodedBmsText { - encoding: DetectedBmsEncoding; - text: string; -} - -export function decodeBmsText(buffer: Buffer): DecodedBmsText { - if (hasUtf8Bom(buffer)) { - return { - encoding: 'utf8', - text: decodeUtf8Text(buffer), - }; - } - if (hasUtf16LeBom(buffer)) { - return { - encoding: 'utf16le', - text: decodeUtf16LeText(buffer), - }; - } - if (hasUtf16BeBom(buffer)) { - return { - encoding: 'utf16be', - text: decodeUtf16BeText(buffer), - }; - } - // Honor an explicit `#CHARSET` directive at the top of the chart before falling into automatic detection. The BMS - // spec authors `#CHARSET` near the start of the file before any non-ASCII text, so a latin1 first-pass (which maps - // every byte 1:1 to a code point) reliably surfaces the directive without depending on the autodetect heuristics. - const declaredCharset = extractDeclaredBmsCharset(decodeLatin1Text(buffer)); - if (declaredCharset) { - const decoded = decodeWithDeclaredCharset(buffer, declaredCharset); - if (decoded) { - return decoded; - } - } - if (isAsciiText(buffer)) { - return { - encoding: 'utf8', - text: decodeUtf8Text(buffer), - }; - } - - const candidates: Array<{ encoding: DetectedBmsEncoding; bias: number }> = [ - { encoding: 'shift_jis', bias: 5 }, - { encoding: 'utf8', bias: 4 }, - { encoding: 'euc-jp', bias: 3 }, - { encoding: 'latin1', bias: -5 }, - ]; - - let best: DecodedBmsText | undefined; - let bestScore = Number.NEGATIVE_INFINITY; - - for (const candidate of candidates) { - const text = iconv.decode(buffer, candidate.encoding); - const score = scoreDecodedBmsText(text, candidate.bias); - if (score > bestScore) { - bestScore = score; - best = { - encoding: candidate.encoding, - text, - }; - } - } - - return ( - best ?? { - encoding: 'utf8', - text: decodeUtf8Text(buffer), - } - ); -} - -function decodeLatin1Text(buffer: Buffer): string { - return buffer.toString('latin1'); -} - -/** - * Maps the canonical charset name produced by {@link canonicalizeBmsCharset} onto the variant `decodeBmsText` returns, - * then runs the decode through `iconv-lite`. `undefined` for unsupported encodings so the caller skips the - * explicit-charset path and falls back to automatic detection. - */ -function decodeWithDeclaredCharset(buffer: Buffer, charset: string): DecodedBmsText | undefined { - switch (charset) { - case 'utf-8': - return { encoding: 'utf8', text: decodeUtf8Text(buffer) }; - case 'utf-16le': - return { encoding: 'utf16le', text: decodeUtf16LeText(buffer) }; - case 'utf-16be': - return { encoding: 'utf16be', text: decodeUtf16BeText(buffer) }; - case 'shift_jis': - return { encoding: 'shift_jis', text: iconv.decode(buffer, 'shift_jis') }; - case 'euc-jp': - return { encoding: 'euc-jp', text: iconv.decode(buffer, 'euc-jp') }; - case 'iso-8859-1': - return { encoding: 'latin1', text: iconv.decode(buffer, 'latin1') }; - default: - return undefined; - } -} - -function scoreDecodedBmsText(text: string, bias: number): number { - let score = bias; - if (text.length === 0) { - return Number.NEGATIVE_INFINITY; - } - - const textStats = collectTextStatistics(text); - score -= textStats.replacementCount * 120; - score -= textStats.nullCount * 80; - score -= textStats.lowControlCount * 8; - - const lines = text.split(/\r?\n/); - let hashLines = 0; - let objectLines = 0; - let headerLines = 0; - let knownCommandLines = 0; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith('#')) { - continue; - } - - hashLines += 1; - if (OBJECT_DATA_LINE.test(trimmed)) { - objectLines += 1; - } else if (HEADER_LINE.test(trimmed)) { - headerLines += 1; - } - if (BMS_KNOWN_COMMAND_LINE.test(trimmed)) { - knownCommandLines += 1; - } - } - - score += hashLines * 0.4; - score += objectLines * 14; - score += headerLines * 8; - score += knownCommandLines * 3; - - const printableRatio = textStats.printableCount / Math.max(1, text.length); - score += printableRatio * 20; - - score += Math.min(40, textStats.japaneseCount * 0.02); - - return score; -} - -function collectTextStatistics(text: string): { - replacementCount: number; - nullCount: number; - lowControlCount: number; - printableCount: number; - japaneseCount: number; -} { - let replacementCount = 0; - let nullCount = 0; - let lowControlCount = 0; - let printableCount = 0; - let japaneseCount = 0; - - for (let index = 0; index < text.length; index += 1) { - const code = text.charCodeAt(index); - if (code === 0xfffd) { - replacementCount += 1; - } - if (code === 0x0000) { - nullCount += 1; - } - if ( - (code >= 0x0001 && code <= 0x0008) || - (code >= 0x000b && code <= 0x000c) || - (code >= 0x000e && code <= 0x001f) - ) { - lowControlCount += 1; - } - if ( - code === 0x000a || - code === 0x000d || - code === 0x0009 || - (code >= 0x0020 && code <= 0x007e) || - (code >= 0x00a0 && code <= 0x00ff) || - (code >= 0x3000 && code <= 0x30ff) || - (code >= 0x3400 && code <= 0x9fff) - ) { - printableCount += 1; - } - if ((code >= 0x3040 && code <= 0x30ff) || (code >= 0x3400 && code <= 0x9fff)) { - japaneseCount += 1; - } - } - - return { - replacementCount, - nullCount, - lowControlCount, - printableCount, - japaneseCount, - }; -} - -export function decodeUtf8Text(buffer: Buffer): string { - let text = buffer.toString('utf8'); - if (text.charCodeAt(0) === 0xfeff) { - text = text.slice(1); - } - return text; -} - -function decodeUtf16LeText(buffer: Buffer): string { - const offset = hasUtf16LeBom(buffer) ? 2 : 0; - return buffer.subarray(offset).toString('utf16le'); -} - -function decodeUtf16BeText(buffer: Buffer): string { - const offset = hasUtf16BeBom(buffer) ? 2 : 0; - const source = buffer.subarray(offset); - const evenLength = source.length - (source.length % 2); - const swapped = Buffer.allocUnsafe(evenLength); - - for (let index = 0; index < evenLength; index += 2) { - swapped[index] = source[index + 1]; - swapped[index + 1] = source[index]; - } - return swapped.toString('utf16le'); -} - -function hasUtf8Bom(buffer: Buffer): boolean { - return buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf; -} - -function hasUtf16LeBom(buffer: Buffer): boolean { - return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; -} - -function hasUtf16BeBom(buffer: Buffer): boolean { - return buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; -} - -function isAsciiText(buffer: Buffer): boolean { - for (let index = 0; index < buffer.length; index += 1) { - if (buffer[index]! >= 0x80) { - return false; - } - } - return true; -} diff --git a/packages/parser/src/core/bmson.ts b/packages/parser/src/core/bmson.ts index 3abdd733..494928b2 100644 --- a/packages/parser/src/core/bmson.ts +++ b/packages/parser/src/core/bmson.ts @@ -23,6 +23,7 @@ export interface BmsonInfo { mode_hint?: string; judge_rank?: number; total?: number; + ln_type?: number; back_image?: string; eyecatch_image?: string; banner_image?: string; @@ -475,6 +476,18 @@ export function createBmsonPositionResolver( }; } + +/** + * beatoraja bmson extension — long-note type values are `1` (LN), `2` (CN), `3` (HCN). Anything else (including the + * spec-absent `0`) is treated as "unspecified" so the consumer falls back to the chart-level / player default. + */ +function normalizeBmsonLongNoteType(value: unknown): 1 | 2 | 3 | undefined { + if (value === 1 || value === 2 || value === 3) { + return value; + } + return undefined; +} + export function normalizeBmsonInfoForIr(info: BmsonInfo, resolution: number): BeMusicJson['bmson']['info'] { const normalized: BeMusicJson['bmson']['info'] = {}; copyIfString(normalized, 'title', info.title); @@ -509,6 +522,12 @@ export function normalizeBmsonInfoForIr(info: BmsonInfo, resolution: number): Be // round-trip re-parse) sees the canonical value. `total: 0` stays semantically meaningful (lifebar doesn't increase, // per the same spec section). copyIfFiniteNumberAbs(normalized, 'total', info.total); + // beatoraja bmson extension — `info.ln_type` picks the chart-wide long-note type (1: LN, 2: CN, 3: HCN). Per-note + // `t` entries override it downstream. + const lnType = normalizeBmsonLongNoteType(info.ln_type); + if (lnType !== undefined) { + normalized.lnType = lnType; + } if (resolution > 0) { normalized.resolution = resolution; @@ -684,6 +703,10 @@ function normalizeBmsonSoundNotes(input: unknown): BmsonSoundNoteEntry[] { if (typeof raw.c === 'boolean') { note.c = raw.c; } + const noteType = normalizeBmsonLongNoteType(raw.t); + if (noteType !== undefined) { + note.t = noteType; + } notes.push(note); } return notes; @@ -817,6 +840,10 @@ function normalizeBmsonInfoFromIr(input: unknown): BeMusicJson['bmson']['info'] copyIfFiniteNumber(info, 'initBpm', raw.initBpm ?? raw.init_bpm); copyIfPositiveFiniteNumber(info, 'judgeRank', raw.judgeRank ?? raw.judge_rank); copyIfFiniteNumberAbs(info, 'total', raw.total); + const lnType = normalizeBmsonLongNoteType(raw.lnType ?? raw.ln_type); + if (lnType !== undefined) { + info.lnType = lnType; + } const resolution = normalizePositiveInteger(raw.resolution); if (resolution !== undefined && resolution > 0) { diff --git a/packages/parser/src/core/control-flow.ts b/packages/parser/src/core/control-flow.ts index 602f4ecb..8db143a5 100644 --- a/packages/parser/src/core/control-flow.ts +++ b/packages/parser/src/core/control-flow.ts @@ -2,12 +2,11 @@ import { cloneJson, type BmsControlFlowCommand, type BmsControlFlowEntry, - type BeMusicEvent, type BeMusicJson, normalizeChannel, normalizeObjectKey, } from '@be-music/json'; -import { collectNonZeroObjectTokens, sortAndNormalizeEvents, upsertMeasureLength } from './event-utils.ts'; +import { buildBmsObjectLineEntry, sortAndNormalizeEvents, upsertMeasureLength } from './event-utils.ts'; type ControlFlowCommand = BmsControlFlowCommand; @@ -114,42 +113,46 @@ export function createControlFlowObjectEntry( data: string, base: 36 | 62 = 36, ): Extract | undefined { - if (channel === '02') { - const measureLength = Number.parseFloat(data); - if (!Number.isFinite(measureLength) || measureLength <= 0) { - return undefined; - } - return { - kind: 'object', - measure, - channel, - events: [], - measureLength, - }; - } - - const parsed = collectNonZeroObjectTokens(data, base); - const events: BeMusicEvent[] = []; - for (const token of parsed.tokens) { - events.push({ - measure, - channel, - position: [token.index, parsed.tokenCount], - value: token.value, - }); - } - - if (events.length === 0) { + // Same line interpretation as the strict parse path — `buildBmsObjectLineEntry` owns the rule so captured + // control-flow bodies can never drift from regular object lines. + const entry = buildBmsObjectLineEntry(measure, channel, data, base); + if (!entry) { return undefined; } return { kind: 'object', - measure, - channel, - events, + ...entry, }; } +/** + * Maps real-world control-flow spelling variants onto their canonical commands: `#END IF` / bare `#END` → `#ENDIF`, + * `#ELSE IF n` → `#ELSEIF n`. hitkey's command memo records these misspellings in released charts; without the + * aliases the enclosing `#IF` never closes, and on a non-matching `#RANDOM` roll every line down to EOF silently + * disappears. `#END ` and a plain `#ELSE` are NOT aliased — they fall through to normal header + * handling. + */ +export function resolveControlFlowAliasDirective( + command: string, + value: string, +): { command: ControlFlowCommand; value?: string } | undefined { + if (command === 'END') { + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.toUpperCase() === 'IF') { + return { command: 'ENDIF' }; + } + return undefined; + } + if (command === 'ELSE') { + const match = value.match(/^IF\b\s*(.*)$/i); + if (match) { + const rest = match[1]!.trim(); + return { command: 'ELSEIF', value: rest.length > 0 ? rest : undefined }; + } + } + return undefined; +} + export function normalizeControlFlowCommand(input: unknown): ControlFlowCommand | undefined { if (typeof input !== 'string') { return undefined; diff --git a/packages/parser/src/core/event-utils.ts b/packages/parser/src/core/event-utils.ts index d8195201..30a23217 100644 --- a/packages/parser/src/core/event-utils.ts +++ b/packages/parser/src/core/event-utils.ts @@ -5,6 +5,7 @@ import { type BeMusicEvent, type BeMusicJson, type BeMusicPosition, + type BmsObjectLineEntry, } from '@be-music/json'; import { normalizeAsciiBase36Code, @@ -29,6 +30,9 @@ export function normalizeBmsonNoteLength(value: unknown): number | undefined { * - The total `tokenCount` (= denominator) counts EVERY 2-char slot including `00` placeholders — needed to compute * fractional beat positions. * - The returned `tokens` array only includes NON-ZERO entries (zeros are silent placeholders, not events). + * - The stream ENDS at the first whitespace character: de-facto BMS implementations treat the object data as one + * contiguous token run, so trailing text (`#00111:0102 some note`) must not fabricate events or shift the + * denominator of the legitimate tokens before it. * * `base` controls the per-character validator: - `36` (default): ASCII `[0-9A-Za-z]` is accepted and lowercase is * FOLDED to uppercase, so `0a` and `0A` collapse to the same ID. - `62`: lowercase is preserved, so `0a` and `0A` are @@ -47,6 +51,9 @@ export function collectNonZeroObjectTokens( let highCode = -1; for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); + if (code === 0x20 || code === 0x09 || code === 0x0b || code === 0x0c) { + break; + } const normalizedCode = normalize(code); if (normalizedCode < 0) { continue; @@ -67,6 +74,57 @@ export function collectNonZeroObjectTokens( return { tokenCount, tokens }; } +/** + * Builds the canonical entry for one BMS object data line (`#mmmcc:data`). + * + * Single implementation of the line → entry rule shared by the strict parse path and the control-flow capture path + * (`#RANDOM` / `#IF` / `#SWITCH` bodies), so both interpret a line identically: + * + * - Channel `02` carries a measure-length factor — parsed as float, non-positive / non-finite values are dropped. + * - Every other channel is a token stream — `00` is a rest, non-zero 2-char tokens become events positioned as + * `[tokenIndex, tokenCount]`. + * + * Returns `undefined` when the line contributes nothing (invalid measure length / only rests). + */ +export function buildBmsObjectLineEntry( + measure: number, + channel: string, + data: string, + base: 36 | 62 = 36, +): BmsObjectLineEntry | undefined { + if (channel === '02') { + const measureLength = Number.parseFloat(data); + if (!Number.isFinite(measureLength) || measureLength <= 0) { + return undefined; + } + return { + measure, + channel, + events: [], + measureLength, + }; + } + + const parsed = collectNonZeroObjectTokens(data, base); + const events: BeMusicEvent[] = []; + for (const token of parsed.tokens) { + events.push({ + measure, + channel, + position: [token.index, parsed.tokenCount], + value: token.value, + }); + } + if (events.length === 0) { + return undefined; + } + return { + measure, + channel, + events, + }; +} + export function sortAndNormalizeEvents( events: Array>, base: 36 | 62 = 36, @@ -193,6 +251,10 @@ function normalizeEventBmsonExtension(value: unknown): BeMusicEvent['bmson'] | u if (typeof raw.c === 'boolean') { extension.c = raw.c; } + // beatoraja bmson extension — per-note long-note type (1: LN, 2: CN, 3: HCN). + if (raw.t === 1 || raw.t === 2 || raw.t === 3) { + extension.t = raw.t; + } // Per-mine gauge damage — sourced from bmson `key_channels[].notes[].damage`. 0 is a valid value (the chart authored // a no-damage decoration mine), so the guard checks `Number.isFinite` rather than truthiness. if (typeof raw.damage === 'number' && Number.isFinite(raw.damage) && raw.damage >= 0) { diff --git a/packages/parser/src/core/parser.ts b/packages/parser/src/core/parser.ts index 6c3d4a92..8e212684 100644 --- a/packages/parser/src/core/parser.ts +++ b/packages/parser/src/core/parser.ts @@ -14,6 +14,7 @@ import { } from '@be-music/json'; import { normalizeAsciiBase36Code } from '@be-music/utils/core'; import { + buildBmsObjectLineEntry, collectNonZeroObjectTokens, normalizeBmsonNoteLength, sortAndNormalizeEvents, @@ -39,10 +40,11 @@ import { createControlFlowObjectEntry, normalizeControlFlowCommand, resolveControlFlow, + resolveControlFlowAliasDirective, type ControlFlowCaptureFrameType, updateControlFlowCaptureStack, } from './control-flow.ts'; -import { extractDeclaredBmsCharset } from './bms-charset.ts'; +import { decodeBmsText, decodeUtf8Text } from './bms-text-decode.ts'; const INDEXED_HEADER_COMMAND = /^(WAV|BMP|BPM|STOP|TEXT|EXRANK|ARGB|CHANGEOPTION|EXWAV|EXBMP|BGA|SCROLL|SPEED|SWBGA)([0-9A-Za-z]{2})$/; @@ -119,7 +121,7 @@ export function parseBms(input: string): BeMusicJson { json.preservation.bms.sourceLines.push(controlFlowObject); } } else { - const objectLine = createBmsObjectLineEntry(measure, channel, data, base); + const objectLine = buildBmsObjectLineEntry(measure, channel, data, base); if (objectLine) { json.preservation.bms.objectLines.push(objectLine); json.preservation.bms.sourceLines.push({ @@ -138,6 +140,21 @@ export function parseBms(input: string): BeMusicJson { } const { command, commandRaw, value } = headerLine; + // Spelling variants (`#END IF` / `#END` / `#ELSE IF n`) normalize onto the canonical control-flow commands — + // captured entries store the canonical form, so round-tripped output is normalized too. + const aliasedDirective = resolveControlFlowAliasDirective(command, value); + if (aliasedDirective) { + const directiveEntry: BmsSourceLineEntry = { + kind: 'directive', + command: aliasedDirective.command, + value: aliasedDirective.value, + }; + json.bms.controlFlow.push(directiveEntry); + json.preservation.bms.sourceLines.push(directiveEntry); + updateControlFlowCaptureStack(controlFlowCaptureStack, aliasedDirective.command); + return; + } + if (isControlFlowCommand(command)) { const directiveEntry: BmsSourceLineEntry = { kind: 'directive', @@ -302,7 +319,12 @@ function parseBmsonDocument(document: BmsonDocument): BeMusicJson { }; const noteLength = normalizeBmsonNoteLength(note.l); const noteContinue = typeof note.c === 'boolean' ? note.c : undefined; - if (noteLength !== undefined || noteContinue !== undefined) { + // beatoraja bmson extension — per-note long-note type, only meaningful on actual long notes (`l > 0`). + const noteLongNoteType = + (note.t === 1 || note.t === 2 || note.t === 3) && noteLength !== undefined && noteLength > 0 + ? note.t + : undefined; + if (noteLength !== undefined || noteContinue !== undefined || noteLongNoteType !== undefined) { event.bmson = {}; if (noteLength !== undefined) { event.bmson.l = noteLength; @@ -310,6 +332,9 @@ function parseBmsonDocument(document: BmsonDocument): BeMusicJson { if (noteContinue !== undefined) { event.bmson.c = noteContinue; } + if (noteLongNoteType !== undefined) { + event.bmson.t = noteLongNoteType; + } } if (isBgmNote) { bgmCandidates.push({ pulse, event }); @@ -536,81 +561,9 @@ export function resolveBmsControlFlow(input: BeMusicJson, options: ResolveBmsCon }); } -export { decodeBmsText }; +export { decodeBmsText, decodeUtf8Text, type DecodedBmsText } from './bms-text-decode.ts'; export { canonicalizeBmsCharset, extractDeclaredBmsCharset } from './bms-charset.ts'; -export interface DecodedBmsText { - encoding: 'utf8' | 'shift_jis' | 'euc-jp' | 'utf-16le' | 'utf-16be' | 'iso-8859-1'; - text: string; -} - -function decodeBmsText(buffer: Uint8Array): DecodedBmsText { - if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { - return { - encoding: 'utf8', - text: decodeUtf8Text(buffer), - }; - } - // BMS spec — honor `#CHARSET ` at the top of the file before falling back to the shift_jis default. The - // directive is authored before any non-ASCII text, so a latin1 first-pass (every byte → its 0..255 code point) always - // surfaces it. The web `TextDecoder` accepts the same canonical encoding names `canonicalizeBmsCharset` produces - // (utf-8 / shift_jis / euc-jp / utf-16le / utf-16be / iso-8859-1), so we can route directly through it without an - // intermediate library. - const declaredCharset = extractDeclaredBmsCharset(decodeLatin1Text(buffer)); - if (declaredCharset) { - const decoded = decodeWithDeclaredCharset(buffer, declaredCharset); - if (decoded) return decoded; - } - try { - return { - encoding: 'shift_jis', - text: new TextDecoder('shift_jis').decode(buffer), - }; - } catch { - return { - encoding: 'utf8', - text: decodeUtf8Text(buffer), - }; - } -} - -function decodeLatin1Text(buffer: Uint8Array): string { - // `iso-8859-1` is required by the WHATWG Encoding spec, so every browser and Node ships it. Maps every byte 1:1 to a - // Unicode code point in [0, 255], which lets the `#CHARSET` scan look at the file's bytes without misinterpretation - // regardless of the actual encoding. - return new TextDecoder('iso-8859-1').decode(buffer); -} - -function decodeWithDeclaredCharset(buffer: Uint8Array, charset: string): DecodedBmsText | undefined { - // BMS `#CHARSET` declares the encoding for the file. We map the canonicalized name onto a `TextDecoder` label and - // strip a leading BOM where applicable. `TextDecoder` throws synchronously for unrecognized labels, which we treat as - // "fall back to autodetection" — same as a value that didn't canonicalize. - try { - switch (charset) { - case 'utf-8': - return { encoding: 'utf8', text: decodeUtf8Text(buffer) }; - case 'shift_jis': - return { encoding: 'shift_jis', text: new TextDecoder('shift_jis').decode(buffer) }; - case 'euc-jp': - return { encoding: 'euc-jp', text: new TextDecoder('euc-jp').decode(buffer) }; - case 'utf-16le': - return { encoding: 'utf-16le', text: new TextDecoder('utf-16le').decode(buffer).replace(/^/u, '') }; - case 'utf-16be': - return { encoding: 'utf-16be', text: new TextDecoder('utf-16be').decode(buffer).replace(/^/u, '') }; - case 'iso-8859-1': - return { encoding: 'iso-8859-1', text: new TextDecoder('iso-8859-1').decode(buffer) }; - default: - return undefined; - } - } catch { - return undefined; - } -} - -function decodeUtf8Text(buffer: Uint8Array): string { - return new TextDecoder('utf-8').decode(buffer).replace(/^\ufeff/u, ''); -} - function pushObjectDataLine( json: BeMusicJson, measure: number, @@ -638,45 +591,6 @@ function pushObjectDataLine( } } -function createBmsObjectLineEntry( - measure: number, - channel: string, - data: string, - base: 36 | 62 = 36, -): BmsObjectLineEntry | undefined { - if (channel === '02') { - const measureLength = Number.parseFloat(data); - if (!Number.isFinite(measureLength) || measureLength <= 0) { - return undefined; - } - return { - measure, - channel, - events: [], - measureLength, - }; - } - - const parsed = collectNonZeroObjectTokens(data, base); - const events: BeMusicEvent[] = []; - for (const token of parsed.tokens) { - events.push({ - measure, - channel, - position: [token.index, parsed.tokenCount], - value: token.value, - }); - } - if (events.length === 0) { - return undefined; - } - return { - measure, - channel, - events, - }; -} - interface ParsedObjectDataLine { measure: number; channel: string; @@ -1664,7 +1578,7 @@ function normalizeBmsObjectLineEntry(input: unknown): BmsObjectLineEntry | undef if (normalizedEvents.length === 0 && measureLength === undefined) { const fallbackData = typeof raw.data === 'string' ? raw.data.trim() : ''; - return createBmsObjectLineEntry(measure, channel, fallbackData); + return buildBmsObjectLineEntry(measure, channel, fallbackData); } return { diff --git a/packages/parser/src/index.test.ts b/packages/parser/src/index.test.ts index 43f04c9f..d0bc1d7e 100644 --- a/packages/parser/src/index.test.ts +++ b/packages/parser/src/index.test.ts @@ -3,7 +3,14 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, test } from 'vitest'; import { BMS_JSON_FORMAT } from '../../json/src/index.ts'; -import { decodeBmsText, parseBmson, parseChart, parseChartFile, resolveBmsControlFlow } from './index.ts'; +import { + decodeBmsText, + decodeUtf8Text, + parseBmson, + parseChart, + parseChartFile, + resolveBmsControlFlow, +} from './index.ts'; const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const unifiedBmsChartPath = resolve(rootDir, 'examples/test/four-measure-command-combo-test.bms'); @@ -87,6 +94,54 @@ describe('parser', () => { expect(parsed.metadata.bpm).toBe(120); }); + test('BMS: accepts #END IF / #END / #ELSE IF control-flow spelling variants', () => { + // These misspellings exist in released charts. Without the aliases the #IF block never closes, so a + // non-matching #RANDOM roll silently dropped every line down to EOF. + const source = [ + '#BPM 120', + '#RANDOM 2', + '#IF 1', + '#00111:01', + '#ELSE IF 2', + '#00111:02', + '#END IF', + '#00211:03', + '', + ].join('\n'); + const parsed = parseChart(source); + + const branch1 = resolveBmsControlFlow(parsed, { random: () => 0 }); + expect(branch1.events.map((event) => `${event.measure}:${event.value}`)).toEqual(['1:01', '2:03']); + expect(branch1.metadata.extras.END).toBeUndefined(); + + const branch2 = resolveBmsControlFlow(parsed, { random: () => 0.9999999 }); + expect(branch2.events.map((event) => `${event.measure}:${event.value}`)).toEqual(['1:02', '2:03']); + + // Bare `#END` also closes the block; the measure-2 note must survive a non-matching roll. + const bareEndSource = ['#BPM 120', '#RANDOM 2', '#IF 1', '#00111:01', '#END', '#00211:03', ''].join('\n'); + const bareEnd = resolveBmsControlFlow(parseChart(bareEndSource), { random: () => 0.9999999 }); + expect(bareEnd.events.map((event) => `${event.measure}:${event.value}`)).toEqual(['2:03']); + + // `#END ` is NOT a control-flow alias — it stays an unknown header. + const endOther = parseChart('#BPM 120\n#END CREDITS\n#00111:01\n'); + expect(endOther.metadata.extras.END).toBe('CREDITS'); + }); + + test('BMS: object data is truncated at the first whitespace', () => { + // De-facto rule: the channel stream is one contiguous token run. Trailing text after whitespace must neither + // fabricate events (e.g. "junk" → JU/NK) nor inflate the denominator of the tokens before it. + const parsed = parseChart('#BPM 120\n#00111:0102 0304\n#00211:0102 junk words\n'); + + expect(parsed.events.filter((event) => event.measure === 1)).toEqual([ + { measure: 1, channel: '11', position: [0, 2], value: '01' }, + { measure: 1, channel: '11', position: [1, 2], value: '02' }, + ]); + expect(parsed.events.filter((event) => event.measure === 2)).toEqual([ + { measure: 2, channel: '11', position: [0, 2], value: '01' }, + { measure: 2, channel: '11', position: [1, 2], value: '02' }, + ]); + }); + test('BMS: accepts CR-only line endings', () => { const parsed = parseChart('#TITLE CR Only\r#BPM 150\r#00111:01\r'); @@ -879,6 +934,37 @@ describe('parser', () => { expect(json.resources.stop[stopKeys[0]!]).toBeCloseTo(48, 6); }); + test('bmson: info.ln_type and notes[].t are preserved in the IR (beatoraja extension)', () => { + const document = { + version: '1.0.0', + info: { title: 'LN Type', init_bpm: 120, ln_type: 2 }, + sound_channels: [ + { + name: 'a.wav', + notes: [ + { x: 1, y: 0, l: 240, t: 3 }, + { x: 2, y: 480, l: 240 }, + { x: 3, y: 960, l: 0, t: 2 }, // t on a non-long note is dropped (only meaningful when l > 0) + { x: 4, y: 1440, l: 240, t: 9 }, // out-of-range t is dropped + ], + }, + ], + }; + const json = parseBmson(JSON.stringify(document)); + + expect(json.bmson.info.lnType).toBe(2); + const byChannel = new Map(json.events.map((event) => [event.channel, event])); + expect(byChannel.get('11')?.bmson?.t).toBe(3); + expect(byChannel.get('12')?.bmson?.t).toBeUndefined(); + expect(byChannel.get('13')?.bmson?.t).toBeUndefined(); + expect(byChannel.get('14')?.bmson?.t).toBeUndefined(); + + // JSON round-trip keeps the per-note type and the chart-level type. + const reparsed = parseChart(JSON.stringify(json), 'json'); + expect(reparsed.bmson.info.lnType).toBe(2); + expect(reparsed.events.find((event) => event.channel === '11')?.bmson?.t).toBe(3); + }); + test('bmson: key_channels mines route through the same mode_hint lane map onto Dx / Ex channels', () => { const json = parseBmson( JSON.stringify({ @@ -1334,4 +1420,53 @@ describe('parser', () => { expect(decoded.encoding).toBe('shift_jis'); expect(decoded.text).toContain('テスト'); }); + + test('decodeUtf8Text: decodes UTF-8 bytes and strips a leading BOM', () => { + const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode('#TITLE éclair\n')]); + expect(decodeUtf8Text(withBom)).toBe('#TITLE éclair\n'); + expect(decodeUtf8Text(new TextEncoder().encode('#TITLE plain\n'))).toBe('#TITLE plain\n'); + }); + + test('decodeBmsText: decodes UTF-16LE charts via their BOM', () => { + const source = '#TITLE てすと\n#BPM 150\n#00111:01\n'; + const bytes = new Uint8Array(Buffer.from(`${source}`, 'utf16le')); + const decoded = decodeBmsText(bytes); + expect(decoded.encoding).toBe('utf-16le'); + const parsed = parseChart(decoded.text); + expect(parsed.metadata.title).toBe('てすと'); + expect(parsed.metadata.bpm).toBe(150); + expect(parsed.events).toHaveLength(1); + }); + + test('decodeBmsText: decodes UTF-16BE charts via their BOM', () => { + const source = '#TITLE てすと\n#BPM 150\n#00111:01\n'; + const littleEndian = Buffer.from(`${source}`, 'utf16le'); + const bytes = new Uint8Array(littleEndian.length); + for (let index = 0; index < littleEndian.length; index += 2) { + bytes[index] = littleEndian[index + 1]!; + bytes[index + 1] = littleEndian[index]!; + } + const decoded = decodeBmsText(bytes); + expect(decoded.encoding).toBe('utf-16be'); + expect(parseChart(decoded.text).metadata.title).toBe('てすと'); + }); + + test('decodeBmsText: detects BOM-less UTF-8 via strict validation', () => { + const bytes = new TextEncoder().encode('#TITLE 灼熱\n#BPM 150\n#00111:01\n'); + const decoded = decodeBmsText(bytes); + expect(decoded.encoding).toBe('utf8'); + expect(parseChart(decoded.text).metadata.title).toBe('灼熱'); + }); + + test('decodeBmsText: scores BOM-less EUC-JP charts as euc-jp', () => { + // "テスト" in EUC-JP (A5C6 A5B9 A5C8) — invalid as strict UTF-8, half-width-kana garbage under shift_jis, clean + // kana under euc-jp, so the scoring walk must land on euc-jp. + const eucTitle = new Uint8Array([0xa5, 0xc6, 0xa5, 0xb9, 0xa5, 0xc8]); + const head = new TextEncoder().encode('#TITLE '); + const tail = new TextEncoder().encode('\n#BPM 150\n#00111:01\n'); + const bytes = new Uint8Array([...head, ...eucTitle, ...tail]); + const decoded = decodeBmsText(bytes); + expect(decoded.encoding).toBe('euc-jp'); + expect(parseChart(decoded.text).metadata.title).toBe('テスト'); + }); }); diff --git a/packages/player-tui/CHANGELOG.md b/packages/player-tui/CHANGELOG.md index 6a889c0d..2aa9755d 100644 --- a/packages/player-tui/CHANGELOG.md +++ b/packages/player-tui/CHANGELOG.md @@ -1,5 +1,38 @@ # @be-music/player +## 0.3.0 + +### Minor Changes + +- ca1012c: Make the TUI player work as a Node single executable application (SEA). + + - Gameplay, UI, and BGA-video workers are now spawned from an embedded copy of the SEA bundle (eval workers dispatched by the new `sea-main` entry on a workerData role marker); previously the worker URL resolution threw under SEA and playback silently never started. + - The CLI entry refuses to start from a worker thread, since inside SEA workers `process.argv[1]` still equals `process.execPath`. + - `@uwx/libav.js-fat` (video BGA) loads through the SEA-aware optional-module loader, so a `node_modules` directory next to the executable now works. + - SEA binaries embed `node-web-audio-api` (with its dependency closure, filtered to the target platform's native addon) and `@uwx/libav.js-fat` (filtered to the wasm build actually used at runtime), extracting them once into `~/.be-music/sea-embedded-modules` at startup, so audio playback and video BGA work out of the box; a `node_modules` directory next to the executable still takes precedence. + +### Patch Changes + +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [254e213] +- Updated dependencies [7bbf052] +- Updated dependencies [811cdfc] +- Updated dependencies [d0bb321] +- Updated dependencies [ebfdba7] +- Updated dependencies [ca1012c] +- Updated dependencies [6ce9173] +- Updated dependencies [7802f98] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/player@0.5.0 + - @be-music/audio-renderer@0.2.3 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.2.6 ### Patch Changes diff --git a/packages/player-tui/package.json b/packages/player-tui/package.json index c9c9c956..4a1d8930 100644 --- a/packages/player-tui/package.json +++ b/packages/player-tui/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-tui", - "version": "0.2.6", + "version": "0.3.0", "description": "Terminal UI and CLI frontend for the be-music player", "license": "MIT", "bin": { diff --git a/packages/player-tui/src/bga-video.ts b/packages/player-tui/src/bga-video.ts index 9ec94cc0..1aa37ccf 100644 --- a/packages/player-tui/src/bga-video.ts +++ b/packages/player-tui/src/bga-video.ts @@ -3,6 +3,8 @@ import { basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Worker } from 'node:worker_threads'; import { createAbortError, isAbortError, throwIfAborted } from '@be-music/utils/core'; +import { loadOptionalNodeModule } from '@be-music/utils/optional-node-module'; +import { createSeaWorker, isSeaRuntime } from './node/sea-worker.ts'; const SUPPORTED_VIDEO_CODECS = new Set(['mpeg1video', 'h264', 'mjpeg']); // Keep chunks small so ff_decode_multi never allocates too many full-size frames at once. @@ -232,15 +234,18 @@ export async function decodeVideoFramesToSourceFramesInWorker( } = {}, ): Promise<{ codecName: 'mpeg1video' | 'h264' | 'mjpeg'; frameCount: number; durationSeconds?: number } | undefined> { throwIfAborted(signal); - const worker = new Worker(resolveBgaVideoWorkerUrl(), { - workerData: { - videoPath, - mode, - stopAfterFirstFrame: Boolean(options.stopAfterFirstFrame), - } satisfies VideoDecodeWorkerInitData, - execArgv: resolveBgaVideoWorkerExecArgv(), - env: resolveBgaVideoWorkerEnv(), - }); + const workerInitData = { + videoPath, + mode, + stopAfterFirstFrame: Boolean(options.stopAfterFirstFrame), + } satisfies VideoDecodeWorkerInitData; + const worker = isSeaRuntime() + ? createSeaWorker('bga-video-worker', workerInitData) + : new Worker(resolveBgaVideoWorkerUrl(), { + workerData: workerInitData, + execArgv: resolveBgaVideoWorkerExecArgv(), + env: resolveBgaVideoWorkerEnv(), + }); return await new Promise((resolve, reject) => { let settled = false; @@ -628,8 +633,22 @@ async function createLibAvInstance(): Promise { (globalThis as { self?: unknown }).self = globalThis; } - const libAvModule = (await import('@uwx/libav.js-fat')) as unknown as LibAvModule; - return libAvModule.default.LibAV({ + // In a SEA binary the bare-specifier import always fails; the helper retries from a `node_modules` + // directory next to the executable (or the working directory) before giving up. + const loadedModule = (await loadOptionalNodeModule('@uwx/libav.js-fat', () => import('@uwx/libav.js-fat'))) as + | LibAvModule + | LibAvFactory + | undefined; + if (!loadedModule) { + throw new Error('@uwx/libav.js-fat is unavailable; cannot decode video BGA.'); + } + // `import()` wraps the CJS exports in a namespace whose `default` holds them; the createRequire fallback + // used inside a SEA returns the exports object directly. Accept both shapes. + const libAvFactory = 'default' in loadedModule ? loadedModule.default : loadedModule; + if (typeof libAvFactory.LibAV !== 'function') { + throw new Error('@uwx/libav.js-fat does not expose a LibAV factory; cannot decode video BGA.'); + } + return libAvFactory.LibAV({ noworker: true, variant: 'fat', }); diff --git a/packages/player-tui/src/bga.ts b/packages/player-tui/src/bga.ts index fbe598be..889b7222 100644 --- a/packages/player-tui/src/bga.ts +++ b/packages/player-tui/src/bga.ts @@ -8,7 +8,12 @@ import { resolveFirstExistingPath } from '@be-music/utils/path'; import { invokeWorkerizedFunction, workerize } from '@be-music/utils/workerize'; import type { BeMusicJson } from '@be-music/json'; import { createTimingResolver } from '@be-music/audio-renderer'; -import { buildBgaTimelines, pickActiveBgaCue, type BgaCue } from '@be-music/player/core/bga-timeline'; +import { + buildBgaTimelines, + DEFAULT_POOR_BGA_DISPLAY_SECONDS, + pickActiveBgaCue, + type BgaCue, +} from '@be-music/player/core/bga-timeline'; import { decode as decodeBmpFast } from 'fast-bmp'; import { decode as decodePngFast } from 'fast-png'; import jpeg from 'jpeg-js'; @@ -18,7 +23,6 @@ import { DEFAULT_IMAGE_RESIZE_ALGORITHM, type ImageResizeAlgorithm } from '@be-m const MAX_NORMAL_BGA_COMPOSITE_LAYERS = 3; const DEFAULT_BGA_ASCII_WIDTH = 34; const DEFAULT_BGA_ASCII_HEIGHT = 20; -const DEFAULT_POOR_BGA_DISPLAY_SECONDS = 2; const KITTY_GRAPHICS_PIXEL_SCALE = 4; const TRANSPARENT_ALPHA_THRESHOLD = 16; const VIDEO_BLACK_BORDER_THRESHOLD = 8; diff --git a/packages/player-tui/src/cli.ts b/packages/player-tui/src/cli.ts index f4db7a01..111da58a 100644 --- a/packages/player-tui/src/cli.ts +++ b/packages/player-tui/src/cli.ts @@ -1,10 +1,16 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isMainThread } from 'node:worker_threads'; import { main } from './cli/runner.ts'; export * from './cli/runner.ts'; function isCliEntryPoint(): boolean { + // In a SEA binary the bundle re-runs inside eval workers (see `./sea-main.ts`), where + // `process.argv[1]` still equals `process.execPath` — never start the CLI from a worker thread. + if (!isMainThread) { + return false; + } const entry = process.argv[1]; if (!entry) { return false; diff --git a/packages/player-tui/src/cli/runner.ts b/packages/player-tui/src/cli/runner.ts index 45cd9173..04a120bb 100644 --- a/packages/player-tui/src/cli/runner.ts +++ b/packages/player-tui/src/cli/runner.ts @@ -26,6 +26,7 @@ import { type ImageResizeAlgorithm, } from '@be-music/player/image-resize-algorithm'; import { runNodeGameplayRuntime } from '../node/node-gameplay-runtime.ts'; +import { ensureSeaEmbeddedNodeModules } from '../node/sea-embedded-modules.ts'; import { buildKittyGraphicsDeleteImageSequence, buildKittyGraphicsRenderSequence, @@ -338,6 +339,13 @@ export async function main(): Promise { logFile: resolvedLogFilePath, }); + try { + // Must run before any worker spawn so the extraction directory propagates through the environment. + await ensureSeaEmbeddedNodeModules(); + } catch (error) { + process.stdout.write(`Warning: failed to extract embedded optional modules (${formatCliParseError(error)}).\n`); + } + if (typeof args.judgeWindowMs === 'number' && Number.isFinite(args.judgeWindowMs)) { process.stdout.write(`Warning: debug judge override enabled (BAD window: ${Math.round(args.judgeWindowMs)}ms).\n`); } diff --git a/packages/player-tui/src/node/node-gameplay-runtime.ts b/packages/player-tui/src/node/node-gameplay-runtime.ts index 1ce487e6..46539d6c 100644 --- a/packages/player-tui/src/node/node-gameplay-runtime.ts +++ b/packages/player-tui/src/node/node-gameplay-runtime.ts @@ -9,6 +9,7 @@ import type { PlayerLoadProgress, PlayerSummary } from '@be-music/player/core/en import { PlayerInterruptedError } from '@be-music/player/core/engine'; import { createNodeInputRuntime, type NodeInputRuntime } from './node-input-runtime.ts'; import { createNodeUiRuntime, type NodeUiRuntime } from './node-ui-runtime.ts'; +import { createSeaWorker, isSeaRuntime } from './sea-worker.ts'; import type { NodeGameplayWorkerInboundMessage, NodeGameplayWorkerInitData, @@ -36,11 +37,13 @@ export async function runNodeGameplayRuntime(options: NodeGameplayRuntimeOptions throw createAbortError(); } - const worker = new Worker(resolveNodeGameplayWorkerUrl(), { - workerData: createWorkerInitData(options), - execArgv: resolveNodeGameplayWorkerExecArgv(), - env: resolveNodeGameplayWorkerEnv(), - }); + const worker = isSeaRuntime() + ? createSeaWorker('node-gameplay-worker', createWorkerInitData(options)) + : new Worker(resolveNodeGameplayWorkerUrl(), { + workerData: createWorkerInitData(options), + execArgv: resolveNodeGameplayWorkerExecArgv(), + env: resolveNodeGameplayWorkerEnv(), + }); const writeOutput = options.writeOutput ?? ((text: string) => process.stdout.write(text)); let inputRuntime: NodeInputRuntime | undefined; diff --git a/packages/player-tui/src/node/node-ui-runtime.ts b/packages/player-tui/src/node/node-ui-runtime.ts index 04e01833..a3039105 100644 --- a/packages/player-tui/src/node/node-ui-runtime.ts +++ b/packages/player-tui/src/node/node-ui-runtime.ts @@ -10,6 +10,7 @@ import { resolveHighSpeedMultiplier } from '@be-music/player/core/high-speed-con import type { ImageResizeAlgorithm } from '@be-music/player/image-resize-algorithm'; import type { TuiNoteHeight } from '@be-music/player/core/ui-options'; import { createDeferredUiFlush } from './deferred-ui-flush.ts'; +import { createSeaWorker, isSeaRuntime } from './sea-worker.ts'; import { createUiFramePatchBuilder } from './ui-frame-patch.ts'; import type { NodeUiWorkerInboundMessage, @@ -56,11 +57,13 @@ export interface NodeUiRuntime { } export async function createNodeUiRuntime(options: NodeUiRuntimeOptions): Promise { - const worker = new Worker(resolveNodeUiWorkerUrl(), { - workerData: createWorkerInitData(options), - execArgv: resolveNodeUiWorkerExecArgv(), - env: resolveNodeUiWorkerEnv(), - }); + const worker = isSeaRuntime() + ? createSeaWorker('node-ui-worker', createWorkerInitData(options)) + : new Worker(resolveNodeUiWorkerUrl(), { + workerData: createWorkerInitData(options), + execArgv: resolveNodeUiWorkerExecArgv(), + env: resolveNodeUiWorkerEnv(), + }); const workerReady = await waitForWorkerReady(worker, options.loadSignal, options.onBgaLoadProgress, options.onLog); if (!workerReady.enabled) { return { diff --git a/packages/player-tui/src/node/sea-embedded-modules.test.ts b/packages/player-tui/src/node/sea-embedded-modules.test.ts new file mode 100644 index 00000000..b9766890 --- /dev/null +++ b/packages/player-tui/src/node/sea-embedded-modules.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { OPTIONAL_NODE_MODULE_DIR_ENV } from '@be-music/utils/optional-node-module'; +import { ensureSeaEmbeddedNodeModules } from './sea-embedded-modules.ts'; + +describe('ensureSeaEmbeddedNodeModules', () => { + it('is a no-op outside a single executable application', async () => { + await expect(ensureSeaEmbeddedNodeModules()).resolves.toBeUndefined(); + expect(process.env[OPTIONAL_NODE_MODULE_DIR_ENV]).toBeUndefined(); + }); +}); diff --git a/packages/player-tui/src/node/sea-embedded-modules.ts b/packages/player-tui/src/node/sea-embedded-modules.ts new file mode 100644 index 00000000..de7a8802 --- /dev/null +++ b/packages/player-tui/src/node/sea-embedded-modules.ts @@ -0,0 +1,85 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { getAsset } from 'node:sea'; +import { OPTIONAL_NODE_MODULE_DIR_ENV } from '@be-music/utils/optional-node-module'; +import { isSeaRuntime } from './sea-worker.ts'; + +// Asset names kept in sync with scripts/build-sea.ts. +const MANIFEST_ASSET_NAME = 'embedded-node-modules.json'; +const MODULE_ASSET_PREFIX = 'embedded-node-modules/'; + +interface EmbeddedModulesManifest { + cacheKey: string; + files: string[]; +} + +/** + * Extract the optional node modules embedded as SEA assets (native addons cannot be loaded from memory, so + * they must exist on disk) into a content-addressed cache directory under `~/.be-music`, and expose that + * directory to `loadOptionalNodeModule` through {@link OPTIONAL_NODE_MODULE_DIR_ENV}, which worker threads + * inherit through the environment. + * + * Returns the base directory containing the extracted `node_modules`, or `undefined` when not running as a + * SEA or when the binary has no embedded modules. + */ +export async function ensureSeaEmbeddedNodeModules(): Promise { + if (!isSeaRuntime()) { + return undefined; + } + const manifest = readEmbeddedModulesManifest(); + if (!manifest) { + return undefined; + } + const baseDir = join(homedir(), '.be-music', 'sea-embedded-modules', manifest.cacheKey); + if (!existsSync(completeMarkerPath(baseDir))) { + await extractEmbeddedModules(manifest, baseDir); + } + process.env[OPTIONAL_NODE_MODULE_DIR_ENV] ??= baseDir; + return baseDir; +} + +function completeMarkerPath(baseDir: string): string { + return join(baseDir, '.complete'); +} + +function readEmbeddedModulesManifest(): EmbeddedModulesManifest | undefined { + try { + const manifest = JSON.parse(getAsset(MANIFEST_ASSET_NAME, 'utf8')) as EmbeddedModulesManifest; + if (typeof manifest.cacheKey !== 'string' || manifest.cacheKey.length === 0 || !Array.isArray(manifest.files)) { + return undefined; + } + return manifest; + } catch { + // The binary was built without embedded modules. + return undefined; + } +} + +async function extractEmbeddedModules(manifest: EmbeddedModulesManifest, baseDir: string): Promise { + // A base directory without the completion marker is a leftover from an interrupted extraction. + if (existsSync(baseDir)) { + await rm(baseDir, { recursive: true, force: true }); + } + // Stage into a per-process directory and rename at the end so a concurrently launched player never sees a + // half-written cache; losing the rename race to another process is fine because the content is identical. + const stagingDir = `${baseDir}.staging-${process.pid}`; + try { + for (const relativePath of manifest.files) { + if (relativePath.split('/').includes('..')) { + continue; + } + const filePath = join(stagingDir, 'node_modules', ...relativePath.split('/')); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, Buffer.from(getAsset(`${MODULE_ASSET_PREFIX}${relativePath}`))); + } + await writeFile(completeMarkerPath(stagingDir), ''); + await rename(stagingDir, baseDir); + } catch (error) { + await rm(stagingDir, { recursive: true, force: true }); + if (!existsSync(completeMarkerPath(baseDir))) { + throw error; + } + } +} diff --git a/packages/player-tui/src/node/sea-worker.test.ts b/packages/player-tui/src/node/sea-worker.test.ts new file mode 100644 index 00000000..3b9c087f --- /dev/null +++ b/packages/player-tui/src/node/sea-worker.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isSeaRuntime, resolveSeaWorkerRole, SEA_WORKER_ROLE_FIELD, SEA_WORKER_ROLES } from './sea-worker.ts'; + +describe('resolveSeaWorkerRole', () => { + it.each(SEA_WORKER_ROLES)('resolves the %s role from workerData', (role) => { + expect(resolveSeaWorkerRole({ [SEA_WORKER_ROLE_FIELD]: role })).toBe(role); + }); + + it('keeps the rest of workerData irrelevant to the resolution', () => { + expect( + resolveSeaWorkerRole({ + [SEA_WORKER_ROLE_FIELD]: 'node-ui-worker', + json: { metadata: {} }, + mode: 'auto', + }), + ).toBe('node-ui-worker'); + }); + + it('returns undefined for unknown role values', () => { + expect(resolveSeaWorkerRole({ [SEA_WORKER_ROLE_FIELD]: 'some-other-worker' })).toBeUndefined(); + expect(resolveSeaWorkerRole({ [SEA_WORKER_ROLE_FIELD]: 1 })).toBeUndefined(); + expect(resolveSeaWorkerRole({ [SEA_WORKER_ROLE_FIELD]: undefined })).toBeUndefined(); + }); + + it('returns undefined when workerData has no role marker', () => { + expect(resolveSeaWorkerRole({ videoPath: 'movie.mpg', mode: 'base' })).toBeUndefined(); + }); + + it('returns undefined for non-object workerData', () => { + expect(resolveSeaWorkerRole(undefined)).toBeUndefined(); + expect(resolveSeaWorkerRole(null)).toBeUndefined(); + expect(resolveSeaWorkerRole('node-ui-worker')).toBeUndefined(); + }); +}); + +describe('isSeaRuntime', () => { + it('returns false outside a single executable application', () => { + expect(isSeaRuntime()).toBe(false); + }); +}); diff --git a/packages/player-tui/src/node/sea-worker.ts b/packages/player-tui/src/node/sea-worker.ts new file mode 100644 index 00000000..08def8c3 --- /dev/null +++ b/packages/player-tui/src/node/sea-worker.ts @@ -0,0 +1,44 @@ +import { getAsset, isSea } from 'node:sea'; +import { Worker } from 'node:worker_threads'; + +/** + * Marker field injected into `workerData` when a worker is spawned from the SEA bundle asset. The SEA entry + * (`src/sea-main.ts`) reads it before the CLI entry runs to decide which worker module to execute. + */ +export const SEA_WORKER_ROLE_FIELD = '__beMusicSeaWorkerRole'; + +/** Asset name under which the SEA build embeds a copy of the bundle itself (see `scripts/build-sea.ts`). */ +export const SEA_BUNDLE_ASSET_NAME = 'sea-entry.cjs'; + +export const SEA_WORKER_ROLES = ['node-gameplay-worker', 'node-ui-worker', 'bga-video-worker'] as const; + +export type SeaWorkerRole = (typeof SEA_WORKER_ROLES)[number]; + +export function isSeaRuntime(): boolean { + try { + return isSea(); + } catch { + return false; + } +} + +export function resolveSeaWorkerRole(workerData: unknown): SeaWorkerRole | undefined { + if (typeof workerData !== 'object' || workerData === null) { + return undefined; + } + const role = (workerData as Record)[SEA_WORKER_ROLE_FIELD]; + return (SEA_WORKER_ROLES as readonly unknown[]).includes(role) ? (role as SeaWorkerRole) : undefined; +} + +/** + * SEA binaries cannot load worker scripts from disk: the worker `.js` files are not shipped next to the + * executable and the SEA-injected `require` only resolves builtins. Instead, spawn an eval worker that + * re-runs the embedded bundle; the role marker makes `sea-main.ts` execute the matching worker module + * instead of the CLI entry. + */ +export function createSeaWorker(role: SeaWorkerRole, workerData: object): Worker { + return new Worker(getAsset(SEA_BUNDLE_ASSET_NAME, 'utf8'), { + eval: true, + workerData: { [SEA_WORKER_ROLE_FIELD]: role, ...workerData }, + }); +} diff --git a/packages/player-tui/src/sea-main.ts b/packages/player-tui/src/sea-main.ts new file mode 100644 index 00000000..fa824ce4 --- /dev/null +++ b/packages/player-tui/src/sea-main.ts @@ -0,0 +1,26 @@ +import { isMainThread, workerData } from 'node:worker_threads'; +import { resolveSeaWorkerRole } from './node/sea-worker.ts'; + +// Entry of the SEA bundle (see `scripts/build-sea.ts`). The embedded bundle doubles as the script for the +// gameplay / UI / BGA-video workers: SEA binaries cannot load worker files from disk, so those workers are +// spawned as eval workers re-running an asset copy of this bundle (see `./node/sea-worker.ts`). Dispatch on +// the workerData role marker before the CLI entry gets a chance to run. +function resolveSeaEntryModule(): Promise { + const seaWorkerRole = isMainThread ? undefined : resolveSeaWorkerRole(workerData); + if (seaWorkerRole === 'node-gameplay-worker') { + return import('./node/node-gameplay-worker.ts'); + } + if (seaWorkerRole === 'node-ui-worker') { + return import('./node/node-ui-worker.ts'); + } + if (seaWorkerRole === 'bga-video-worker') { + return import('./bga-video-worker.ts'); + } + return import('./cli.ts'); +} + +void resolveSeaEntryModule().catch((error: unknown) => { + const message = error instanceof Error && error.stack ? error.stack : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/packages/player-web-demo/CHANGELOG.md b/packages/player-web-demo/CHANGELOG.md index f292ba91..aa56b6c3 100644 --- a/packages/player-web-demo/CHANGELOG.md +++ b/packages/player-web-demo/CHANGELOG.md @@ -1,5 +1,15 @@ # @be-music/player-web-demo +## 0.3.1 + +### Patch Changes + +- Updated dependencies [f07ff77] +- Updated dependencies [99324e8] + - @be-music/player-web@0.6.2 + - @be-music/lr2-skin@0.1.4 + - @be-music/beatoraja-skin@0.1.2 + ## 0.3.0 ### Minor Changes diff --git a/packages/player-web-demo/package.json b/packages/player-web-demo/package.json index 5eba6b59..acb25fa1 100644 --- a/packages/player-web-demo/package.json +++ b/packages/player-web-demo/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-web-demo", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { @@ -25,10 +25,10 @@ "lil-gui": "^0.21.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260608.1", + "@cloudflare/workers-types": "^4.20260611.1", "ts-ebml": "^3.0.2", "vite": "^8.0.16", "vite-plugin-node-polyfills": "^0.28.0", - "wrangler": "^4.98.0" + "wrangler": "^4.100.0" } } diff --git a/packages/player-web-demo/vite.config.ts b/packages/player-web-demo/vite.config.ts index 03b4df46..067e905a 100644 --- a/packages/player-web-demo/vite.config.ts +++ b/packages/player-web-demo/vite.config.ts @@ -406,13 +406,29 @@ function acknowledgementsPlugin(roots: string[]): Plugin { }; } -// Vite resolves aliases by string `startsWith` match, so longer subpaths must come first — otherwise -// a shorter prefix entry would shadow them. +interface WorkspaceAlias { + find: string; + replacement: string; +} + +function workspaceRootAliasPlugin(entries: readonly WorkspaceAlias[]): Plugin { + return { + name: 'be-music:resolve-workspace-root-aliases', + enforce: 'pre', + resolveId(source) { + const entry = entries.find((candidate) => candidate.find === source); + return entry?.replacement ?? null; + }, + }; +} + +// Vite/Rolldown string aliases are prefix matches, so root package aliases must stay out of `resolve.alias`; otherwise +// `@be-music/utils` can shadow `@be-music/utils/optional-node-module` and resolve to `src/index.ts/optional-node-module`. // // `@be-music/utils` / `@be-music/audio-renderer` / `@be-music/player` keep Node-facing code behind subpath exports or // lazy dynamic imports. Browser imports stay on the browser-safe paths below; disk-facing functions such as // `renderChartFile` are not called from the demo runtime. -const workspaceAliases = [ +const workspaceSubpathAliases: WorkspaceAlias[] = [ { find: '@be-music/audio-renderer/triggers', replacement: resolve(repositoryDir, 'packages/audio-renderer/src/core/triggers.ts'), @@ -430,19 +446,16 @@ const workspaceAliases = [ replacement: resolve(repositoryDir, 'packages/player/src/state-signals.ts'), }, { find: '@be-music/player/core', replacement: resolve(repositoryDir, 'packages/player/src/core') }, - { find: '@be-music/player', replacement: resolve(repositoryDir, 'packages/player/src/index.ts') }, { find: '@be-music/utils/core', replacement: resolve(repositoryDir, 'packages/utils/src/core.ts') }, { find: '@be-music/utils/cli-path', replacement: resolve(repositoryDir, 'packages/utils/src/cli-path.ts') }, { find: '@be-music/utils/log', replacement: resolve(repositoryDir, 'packages/utils/src/log.ts') }, + // Browser-safe by design: Node built-ins load lazily inside the fallback path only (see the module header). + { + find: '@be-music/utils/optional-node-module', + replacement: resolve(repositoryDir, 'packages/utils/src/optional-node-module.ts'), + }, { find: '@be-music/utils/path', replacement: resolve(repositoryDir, 'packages/utils/src/path.ts') }, { find: '@be-music/utils/pcm', replacement: resolve(repositoryDir, 'packages/utils/src/pcm.ts') }, - { find: '@be-music/utils', replacement: resolve(repositoryDir, 'packages/utils/src/index.ts') }, - { find: '@be-music/audio-renderer', replacement: resolve(repositoryDir, 'packages/audio-renderer/src/index.ts') }, - { find: '@be-music/beatoraja-skin', replacement: resolve(repositoryDir, 'packages/beatoraja-skin/src/index.ts') }, - { find: '@be-music/chart', replacement: resolve(repositoryDir, 'packages/chart/src/index.ts') }, - { find: '@be-music/json', replacement: resolve(repositoryDir, 'packages/json/src/index.ts') }, - { find: '@be-music/lr2-skin', replacement: resolve(repositoryDir, 'packages/lr2-skin/src/index.ts') }, - { find: '@be-music/parser', replacement: resolve(repositoryDir, 'packages/parser/src/index.ts') }, // Longer subpaths must precede the main `@be-music/player-web` alias — Vite resolves aliases via `startsWith`, so a // shorter prefix match would shadow them and try to load e.g. `packages/player-web/src/index.ts/scenes` as a file. { @@ -465,11 +478,25 @@ const workspaceAliases = [ find: '@be-music/player-web/runtime', replacement: resolve(repositoryDir, 'packages/player-web/src/runtime/index.ts'), }, +]; + +const workspaceRootAliases: WorkspaceAlias[] = [ + { find: '@be-music/player', replacement: resolve(repositoryDir, 'packages/player/src/index.ts') }, + { find: '@be-music/utils', replacement: resolve(repositoryDir, 'packages/utils/src/index.ts') }, + { find: '@be-music/audio-renderer', replacement: resolve(repositoryDir, 'packages/audio-renderer/src/index.ts') }, + { find: '@be-music/beatoraja-skin', replacement: resolve(repositoryDir, 'packages/beatoraja-skin/src/index.ts') }, + { find: '@be-music/chart', replacement: resolve(repositoryDir, 'packages/chart/src/index.ts') }, + { find: '@be-music/json', replacement: resolve(repositoryDir, 'packages/json/src/index.ts') }, + { find: '@be-music/lr2-skin', replacement: resolve(repositoryDir, 'packages/lr2-skin/src/index.ts') }, + { find: '@be-music/parser', replacement: resolve(repositoryDir, 'packages/parser/src/index.ts') }, { find: '@be-music/player-web', replacement: resolve(repositoryDir, 'packages/player-web/src/index.ts') }, ]; +const workspaceAliases = [...workspaceSubpathAliases, ...workspaceRootAliases]; + export default defineConfig({ plugins: [ + workspaceRootAliasPlugin(workspaceRootAliases), // ts-ebml (used by `makeWebmSeekable` for the recording post-process) reaches into Node's `Buffer` and `events` // module from its CJS dependency chain (`ebml`, `ebml-block`, `int64-buffer`). Vite leaves those unpolyfilled by // default, which surfaces in the browser as `Cannot read properties of undefined (reading 'readVint')` the moment @@ -511,7 +538,7 @@ export default defineConfig({ ]), ], resolve: { - alias: workspaceAliases, + alias: workspaceSubpathAliases, }, optimizeDeps: { exclude: [ diff --git a/packages/player-web/CHANGELOG.md b/packages/player-web/CHANGELOG.md index df0419bc..faffaf68 100644 --- a/packages/player-web/CHANGELOG.md +++ b/packages/player-web/CHANGELOG.md @@ -1,5 +1,33 @@ # @be-music/player-web +## 0.6.2 + +### Patch Changes + +- f07ff77: Match the LR2 groove gauge bar rendering: remove the peak-hold / afterimage bead (LR2 has none — the bar tracks only the live value) and suppress the green clear-zone split for survival gauges (HARD / DEATH render the whole bar red in LR2, since they have no 80% clear border). GROOVE / EASY keep the green ≥80% zone. The per-bead cell selection is now the pure, tested `resolveGrooveGaugeBeads` helper. +- 99324e8: `#xxx97` / `#xxx98` dynamic volume changes in the WebAudio session now apply only to voices triggered after the event, matching the documented semantics and the Node engine. Previously the web runtime wrote the new gain onto the shared bus mixers, retroactively changing the volume of already-playing voices. +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [254e213] +- Updated dependencies [7bbf052] +- Updated dependencies [811cdfc] +- Updated dependencies [d0bb321] +- Updated dependencies [ebfdba7] +- Updated dependencies [ca1012c] +- Updated dependencies [6ce9173] +- Updated dependencies [7802f98] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/player@0.5.0 + - @be-music/audio-renderer@0.2.3 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + - @be-music/lr2-skin@0.1.4 + - @be-music/beatoraja-skin@0.1.2 + ## 0.6.1 ### Patch Changes diff --git a/packages/player-web/package.json b/packages/player-web/package.json index e5aafbee..53407270 100644 --- a/packages/player-web/package.json +++ b/packages/player-web/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player-web", - "version": "0.6.1", + "version": "0.6.2", "description": "Vanilla browser PixiJS core for be-music song selection and gameplay", "license": "MIT", "files": [ diff --git a/packages/player-web/src/chart/preview.ts b/packages/player-web/src/chart/preview.ts index 4f7d5b0c..404498da 100644 --- a/packages/player-web/src/chart/preview.ts +++ b/packages/player-web/src/chart/preview.ts @@ -2,6 +2,7 @@ // Node-only modules (fs / path / fluent-ffmpeg) that Vite can't bundle for the demo target — see // `packages/player-web-demo/vite.config.ts` which explicitly drops the root alias for that reason. import { collectSampleTriggers, createTimingResolver } from '@be-music/audio-renderer/triggers'; +import { usesMonophonicWavPlayback } from '@be-music/chart'; import { normalizeChannel, type BeMusicJson } from '@be-music/json'; import { loadAssetBytes, resolveChartAudioAsset } from '../collection/collection.ts'; import type { BrowserSongAssetSource, BrowserSongEntry } from '../collection/types.ts'; @@ -473,7 +474,7 @@ export class ChartPreviewEngine { this.finishSource(handle); }; try { - if (chart.sourceFormat === 'bms' && activeSameSlot) { + if (usesMonophonicWavPlayback(chart) && activeSameSlot) { this.stopSourceAt(activeSameSlot, when); } if (typeof trigger.sampleDurationSeconds === 'number') { diff --git a/packages/player-web/src/collection/collection.ts b/packages/player-web/src/collection/collection.ts index 5e5b07cc..5e2294db 100644 --- a/packages/player-web/src/collection/collection.ts +++ b/packages/player-web/src/collection/collection.ts @@ -1,6 +1,6 @@ import { unzipSync } from 'fflate'; import { isPlayableChannel, resolveChartPlayVariant as resolveChartPlayVariantForChart } from '@be-music/chart'; -import { extractDeclaredBmsCharset, parseBms, parseBmson } from '@be-music/parser'; +import { decodeBmsText, decodeUtf8Text, parseBms, parseBmson } from '@be-music/parser'; import { extractPlayableNotes } from '@be-music/player/playable-notes'; import { basename, dirname, normalizePath, readFilesIntoEntryMap, runWithConcurrency } from '@be-music/utils/core'; import type { @@ -523,57 +523,11 @@ function formatBytes(bytes: number): string { function parseChart(path: string, bytes: Uint8Array) { if (extensionOf(path) === '.bmson') { - return parseBmson(decodeUtf8(bytes)); - } - return parseBms(decodeBms(bytes)); -} - -function decodeUtf8(bytes: Uint8Array): string { - return new TextDecoder('utf-8').decode(bytes).replace(/^\ufeff/u, ''); -} - -function decodeBms(bytes: Uint8Array): string { - // BOM detection \u2014 UTF-8 BOM unambiguously identifies the chart as UTF-8 encoded. - if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { - return decodeUtf8(bytes); - } - // BMS spec \u2014 honor `#CHARSET ` at the top of the file before falling back to the shift_jis default. The - // first-pass latin1 decode preserves every byte 1:1 so we can scan for the directive without decoding - // misinterpretation. Mirrors the parser's `decodeBmsText` flow so a chart's declared encoding is honored by every - // runtime (CLI / TUI / web). - const declaredCharset = extractDeclaredBmsCharset(new TextDecoder('iso-8859-1').decode(bytes)); - if (declaredCharset) { - const decoded = decodeBmsWithCharset(bytes, declaredCharset); - if (decoded !== undefined) return decoded; - } - try { - return new TextDecoder('shift_jis').decode(bytes).replace(/^\ufeff/u, ''); - } catch { - return decodeUtf8(bytes); - } -} - -function decodeBmsWithCharset(bytes: Uint8Array, charset: string): string | undefined { - // `TextDecoder` accepts the same canonical encoding names `canonicalizeBmsCharset` produces, so the declared charset - // can route directly through it. Any unrecognized label throws synchronously; the caller treats that as "fall back to - // autodetection". - try { - switch (charset) { - case 'utf-8': - return new TextDecoder('utf-8').decode(bytes).replace(/^\ufeff/u, ''); - case 'shift_jis': - case 'euc-jp': - case 'iso-8859-1': - return new TextDecoder(charset).decode(bytes); - case 'utf-16le': - case 'utf-16be': - return new TextDecoder(charset).decode(bytes).replace(/^\ufeff/u, ''); - default: - return undefined; - } - } catch { - return undefined; + return parseBmson(decodeUtf8Text(bytes)); } + // Single canonical decode pipeline (`@be-music/parser`'s `decodeBmsText`) so the web player resolves a chart's + // encoding exactly like the CLI / TUI do \u2014 BOM, `#CHARSET`, and the shift_jis fallback all live in one module. + return parseBms(decodeBmsText(bytes).text); } function isScoreTargetChannel(channel: string): boolean { diff --git a/packages/player-web/src/runtime/web-audio-session.test.ts b/packages/player-web/src/runtime/web-audio-session.test.ts index 713b0a8b..0b4f8425 100644 --- a/packages/player-web/src/runtime/web-audio-session.test.ts +++ b/packages/player-web/src/runtime/web-audio-session.test.ts @@ -246,26 +246,42 @@ describe('createWebAudioSession', () => { expect(source.stop).toHaveBeenCalled(); }); - test('routes #xxx97 / #xxx98 dynamic volume events to the bgm / key mixers via setValueAtTime', () => { - const { audioContext, audioBus, keyMixer, bgmMixer } = createMocks(); + test('applies #xxx97 / #xxx98 dynamic volume only to voices triggered afterward', () => { + const { audioContext, audioBus, trackedBufferSources, createdGains, keyMixer, bgmMixer } = createMocks(); const session = createWebAudioSession({ audioContext, audioBus, chart: makeChartWithSamples(), - decodedSamples: new Map(), + decodedSamples: new Map([ + ['kick.wav', makeMockAudioBuffer(5)], + ['bgm.wav', makeMockAudioBuffer(5)], + ]), wavCmdVolumeMultipliers: new Map(), }); - // `#xxx97 80` — half-volume on the BGM bus (0x80 / 0xff ≈ 0.5019). + // A BGM voice playing BEFORE the volume change keeps its level: no per-voice gain node, no bus write. + session.triggerEvent?.(mkEvent('01', '03')); + expect(trackedBufferSources[0]!.connectedTo).toBe(bgmMixer); + + // `#xxx97 80` — ~half volume for SUBSEQUENT BGM voices (0x80 / 0xff ≈ 0.5019). session.triggerEvent?.(mkEvent('97', '80')); + // Spec: already-playing voices are untouched — the shared bus mixers must not be written. expect( (bgmMixer.gain as unknown as { setValueAtTime: ReturnType }).setValueAtTime, - ).toHaveBeenCalled(); - // `#xxx98 FF` — unity on the key bus. + ).not.toHaveBeenCalled(); + + // The next BGM voice picks the new level up as its initial per-voice gain. + session.triggerEvent?.(mkEvent('01', '03')); + expect(createdGains).toHaveLength(1); + expect((createdGains[0]!.gain as unknown as { value: number }).value).toBeCloseTo(0x80 / 0xff, 6); + + // `#xxx98 FF` — unity on the key side: no gain node needed, the source connects straight to the key mixer. session.triggerEvent?.(mkEvent('98', 'FF')); + session.triggerEvent?.(mkEvent('11', '01')); expect( (keyMixer.gain as unknown as { setValueAtTime: ReturnType }).setValueAtTime, - ).toHaveBeenCalled(); + ).not.toHaveBeenCalled(); + expect(trackedBufferSources.at(-1)!.connectedTo).toBe(keyMixer); }); test('dispose hard-stops every still-playing source so they do not survive into the next chart', async () => { diff --git a/packages/player-web/src/runtime/web-audio-session.ts b/packages/player-web/src/runtime/web-audio-session.ts index ddc46a5f..46e94d77 100644 --- a/packages/player-web/src/runtime/web-audio-session.ts +++ b/packages/player-web/src/runtime/web-audio-session.ts @@ -3,6 +3,7 @@ import { isBmsKeyVolumeChangeChannel, isPlayLaneSoundChannel, parseBmsDynamicVolumeGain, + usesMonophonicWavPlayback, } from '@be-music/chart'; import { normalizeChannel, @@ -14,9 +15,6 @@ import { import type { AudioSession } from '@be-music/player/core/engine'; import { normalizePath } from '@be-music/utils/core'; import { type AudioBusHandle } from './audio-bus.ts'; -import { logger } from '../logger.ts'; - -const log = logger('web-audio-session'); interface WebAudioSourceHandle { node: AudioBufferSourceNode; @@ -106,8 +104,9 @@ export interface WebAudioSession extends AudioSession { * key-bus compressor sees the input transient stream. * - **Auto-triggered lane sounds (`#xxx01`, `#xxx51..#xxx69` LN-start, `#xxx07` POOR BGA, etc.)** — route through * `audioBus.bgmMixer` so the BGM bed shares the BGM compressor. - * - **`#xxx97` / `#xxx98` dynamic volume events** — interpret as a chart-level absolute gain change against the - * matching mixer (`97 = bgmMixer`, `98 = keyMixer`) using `setValueAtTime` at audio-context "now". + * - **`#xxx97` / `#xxx98` dynamic volume events** — update the session's current BGM / key dynamic gain. Per + * docs/bms-spec.md「#xxx97 / #xxx98」, the new level applies as the initial gain of voices triggered from that + * point on; already-playing voices keep the gain they started with (mirrors the engine's Node mixer behavior). * - **`#xxxD0` / `#xxxE0` (landmine explosions)** — route through `bgmMixer` (the engine fires these via * `triggerEvent` on landmine hit; the explosion is BGM-style, not the player's keysound). * @@ -133,6 +132,11 @@ export function createWebAudioSession(context: WebAudioSessionContext): WebAudio let paused = false; let disposed = false; let started = false; + // `#xxx97` / `#xxx98` current dynamic gains. Spec (docs/bms-spec.md「#xxx97 / #xxx98」): a volume change applies to + // voices triggered from that point on; already-playing voices are untouched. The gain is therefore captured + // per-voice at build time instead of being written onto the shared bus mixers. + let currentBgmDynamicGain = 1; + let currentKeyDynamicGain = 1; const resolveSamplePath = (event: BeMusicEvent): { sampleKey: string; path: string } | undefined => { const sampleKey = normalizeObjectKey(event.value, sampleIdBase); @@ -142,23 +146,25 @@ export function createWebAudioSession(context: WebAudioSessionContext): WebAudio }; /** - * Builds + parents a `BufferSourceNode` to the appropriate mixer with `#WAVCMD` per-slot gain spliced in. Returns - * `undefined` when the slot has no decoded buffer (chart referenced an asset we never loaded, e.g. a missing file). + * Builds + parents a `BufferSourceNode` to the appropriate mixer with the `#WAVCMD` per-slot gain and the current + * `#xxx97` / `#xxx98` dynamic gain spliced in. Returns `undefined` when the slot has no decoded buffer (chart + * referenced an asset we never loaded, e.g. a missing file). */ const buildSourceNode = ( sampleKey: string, path: string, bus: GainNode, + dynamicGain: number, ): WebAudioSourceHandle | undefined => { const buffer = decodedSamples.get(normalizePath(path).toLowerCase()); if (!buffer) return undefined; const node = audioContext.createBufferSource(); node.buffer = buffer; let gain: GainNode | undefined; - const multiplier = wavCmdVolumeMultipliers.get(sampleKey); - if (multiplier !== undefined && multiplier !== 1) { + const combinedGain = (wavCmdVolumeMultipliers.get(sampleKey) ?? 1) * dynamicGain; + if (combinedGain !== 1) { gain = audioContext.createGain(); - gain.gain.value = multiplier; + gain.gain.value = combinedGain; node.connect(gain); gain.connect(bus); } else { @@ -224,7 +230,7 @@ export function createWebAudioSession(context: WebAudioSessionContext): WebAudio const channel = normalizeChannel(event.channel); const isPlayerLane = isPlayLaneSoundChannel(channel); const bus = isPlayerLane ? audioBus.keyMixer : audioBus.bgmMixer; - const built = buildSourceNode(sampleKey, path, bus); + const built = buildSourceNode(sampleKey, path, bus, isPlayerLane ? currentKeyDynamicGain : currentBgmDynamicGain); if (!built) return; const { node, buffer } = built; @@ -240,7 +246,7 @@ export function createWebAudioSession(context: WebAudioSessionContext): WebAudio // throwing for a past timestamp; player-input keysounds and immediate triggers go through the no-`when` path. const startAt = audioContextStartSeconds !== undefined ? Math.max(audioContext.currentTime, audioContextStartSeconds) : undefined; - if (chart.sourceFormat === 'bms') { + if (usesMonophonicWavPlayback(chart)) { const previous = activeBySlot.get(sampleKey); if (previous) { stopSource(previous, startAt); @@ -257,22 +263,16 @@ export function createWebAudioSession(context: WebAudioSessionContext): WebAudio if (disposed) return; const gain = parseBmsDynamicVolumeGain(event.value); if (gain === undefined) return; - // Bgm channel = `97`, key channel = `98`. The dispatcher already gated on - // `isBmsDynamicVolumeChangeChannel(event.channel)` so one of the two predicates must match — but we still pick - // the mixer explicitly so an unrecognized future channel doesn't accidentally retarget the wrong bus. - const target = isBmsBgmVolumeChangeChannel(event.channel) - ? audioBus.bgmMixer - : isBmsKeyVolumeChangeChannel(event.channel) - ? audioBus.keyMixer - : undefined; - if (!target) return; const clamped = Math.max(0, Math.min(1, gain)); - try { - target.gain.setValueAtTime(clamped, audioContext.currentTime); - } catch (error) { - // Sealed AudioParam (extremely rare — bus disposed mid-flight). Drop the event silently; the next prepare - // re-establishes the mixer. - log.warn('failed to apply dynamic volume event', error); + // Bgm channel = `97`, key channel = `98`. The dispatcher already gated on + // `isBmsDynamicVolumeChangeChannel(event.channel)` so one of the two predicates must match — but we still branch + // explicitly so an unrecognized future channel doesn't accidentally retarget the wrong side. The stored gain is + // consumed by `buildSourceNode` for subsequently triggered voices only — never written to the live bus mixers, + // so in-flight voices keep the level they started with (docs/bms-spec.md「#xxx97 / #xxx98」). + if (isBmsBgmVolumeChangeChannel(event.channel)) { + currentBgmDynamicGain = clamped; + } else if (isBmsKeyVolumeChangeChannel(event.channel)) { + currentKeyDynamicGain = clamped; } }; diff --git a/packages/player-web/src/scene/lr2/gameplay-hud.test.ts b/packages/player-web/src/scene/lr2/gameplay-hud.test.ts new file mode 100644 index 00000000..09ce1698 --- /dev/null +++ b/packages/player-web/src/scene/lr2/gameplay-hud.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; +import { + LR2_GROOVE_GAUGE_CLEAR_ZONE_PERCENT, + LR2_GROOVE_GAUGE_UNITS, + resolveGrooveGaugeBeads, +} from './gameplay-hud.ts'; + +// Cell offsets within the LR2 4-cell frame (order: 表赤/表緑/裏赤/裏緑). +const LIT_RED = 0; +const LIT_GREEN = 1; +const UNLIT_RED = 2; +const UNLIT_GREEN = 3; + +describe('resolveGrooveGaugeBeads', () => { + test('emits exactly 50 beads', () => { + expect(resolveGrooveGaugeBeads(50)).toHaveLength(LR2_GROOVE_GAUGE_UNITS); + }); + + test('GROOVE: red below the 80% clear border, green at/above it, lit up to the fill', () => { + // 90% → 45 lit beads. Clear border at 80% → unit 40. + const beads = resolveGrooveGaugeBeads(90); + const at = (i: number) => beads[i]!.cellOffset; + expect(at(0)).toBe(LIT_RED); // lit, below 80% + expect(at(39)).toBe(LIT_RED); // lit, last bead below the clear border + expect(at(40)).toBe(LIT_GREEN); // lit, clear zone + expect(at(44)).toBe(LIT_GREEN); // lit, clear zone (fill = 45) + expect(at(45)).toBe(UNLIT_GREEN); // unlit track, clear zone + expect(at(49)).toBe(UNLIT_GREEN); // unlit track, clear zone + }); + + test('GROOVE: a sub-80% gauge lights only red beads; the clear-zone track stays green', () => { + const beads = resolveGrooveGaugeBeads(50); // 25 lit, all below the 80% border + // No LIT green bead — the fill never reached the clear zone. + expect(beads.some((b) => b.cellOffset === LIT_GREEN)).toBe(false); + expect(beads[0]!.cellOffset).toBe(LIT_RED); + expect(beads[24]!.cellOffset).toBe(LIT_RED); + expect(beads[25]!.cellOffset).toBe(UNLIT_RED); // unlit track, still below 80% + // The clear-zone track (>= 80%) renders green even when unlit, so the 80% border stays visible. + expect(beads[40]!.cellOffset).toBe(UNLIT_GREEN); + expect(beads[49]!.cellOffset).toBe(UNLIT_GREEN); + }); + + test('survival gauges (HARD / DEATH) render the whole bar red — no green clear zone', () => { + const beads = resolveGrooveGaugeBeads(100, { survivalGauge: true }); + expect(beads.every((b) => b.cellOffset === LIT_RED)).toBe(true); + + const partial = resolveGrooveGaugeBeads(90, { survivalGauge: true }); + expect(partial.some((b) => b.cellOffset === LIT_GREEN || b.cellOffset === UNLIT_GREEN)).toBe(false); + expect(partial[44]!.cellOffset).toBe(LIT_RED); // lit, would be green on GROOVE + expect(partial[45]!.cellOffset).toBe(UNLIT_RED); // unlit, would be green on GROOVE + }); + + test('does not paint a peak-hold bead — beads above the live fill are always the unlit track', () => { + // LR2 has no peak/afterimage indicator: every bead past `activeUnits` is unlit, regardless of any prior peak. + const beads = resolveGrooveGaugeBeads(40); // 20 lit + for (let i = 20; i < LR2_GROOVE_GAUGE_UNITS; i += 1) { + expect(beads[i]!.cellOffset === UNLIT_RED || beads[i]!.cellOffset === UNLIT_GREEN).toBe(true); + } + }); + + test('clamps out-of-range / non-finite input', () => { + expect(resolveGrooveGaugeBeads(200).every((b) => b.cellOffset === LIT_RED || b.cellOffset === LIT_GREEN)).toBe(true); + expect(resolveGrooveGaugeBeads(-10).every((b) => b.cellOffset === UNLIT_RED || b.cellOffset === UNLIT_GREEN)).toBe( + true, + ); + expect(resolveGrooveGaugeBeads(Number.NaN)[0]!.cellOffset).toBe(UNLIT_RED); + }); + + test('exposes the LR2 constants', () => { + expect(LR2_GROOVE_GAUGE_UNITS).toBe(50); + expect(LR2_GROOVE_GAUGE_CLEAR_ZONE_PERCENT).toBe(80); + }); +}); diff --git a/packages/player-web/src/scene/lr2/gameplay-hud.ts b/packages/player-web/src/scene/lr2/gameplay-hud.ts index cf6694cc..5f1cef33 100644 --- a/packages/player-web/src/scene/lr2/gameplay-hud.ts +++ b/packages/player-web/src/scene/lr2/gameplay-hud.ts @@ -190,13 +190,54 @@ export function renderNowComboElement( } } +/** LR2 groove gauge is drawn as 50 beads (2 % each). */ +export const LR2_GROOVE_GAUGE_UNITS = 50; +/** GROOVE / EASY clear border — beads at or above this percentage render in the green cell. */ +export const LR2_GROOVE_GAUGE_CLEAR_ZONE_PERCENT = 80; + +export interface GrooveGaugeBead { + unitIndex: number; + /** Offset within the SRC's 4-cell frame: 0 = lit-red, 1 = lit-green, 2 = unlit-red, 3 = unlit-green. */ + cellOffset: number; +} + +/** + * Resolves the per-bead cell selection for the LR2 groove gauge, matching LR2's own cell order + * (`表赤,表緑,裏赤,裏緑` = lit-red, lit-green, unlit-red, unlit-green; see the LR2 default 7K skin comment in + * `7keys/7_LL0.csv`). + * + * - Beads `0..activeUnits-1` are lit; the rest are the unlit track. + * - For GROOVE / EASY, beads at or above the 80 % clear border use the green cell (the "you reached clear" cue); + * below it they use the red cell. + * - Survival gauges (HARD / DEATH) have NO clear border in LR2 — the whole bar renders red, so the green zone is + * suppressed. (LR2 reuses the single `#SRC_GROOVEGAUGE` and lets the binary pick the red cells throughout; the + * red-gauge ops 42/43 signal the type.) + * + * Pure function (no Pixi binding) so the cell-selection logic is unit-tested directly. + */ +export function resolveGrooveGaugeBeads( + percent: number, + options: { survivalGauge?: boolean } = {}, +): GrooveGaugeBead[] { + const clampedPercent = Math.max(0, Math.min(100, Number.isFinite(percent) ? percent : 0)); + const activeUnits = Math.round((clampedPercent / 100) * LR2_GROOVE_GAUGE_UNITS); + const clearZoneUnit = Math.round((LR2_GROOVE_GAUGE_CLEAR_ZONE_PERCENT / 100) * LR2_GROOVE_GAUGE_UNITS); + const beads: GrooveGaugeBead[] = []; + for (let unitIndex = 0; unitIndex < LR2_GROOVE_GAUGE_UNITS; unitIndex += 1) { + const isActive = unitIndex < activeUnits; + const isClearZone = !options.survivalGauge && unitIndex >= clearZoneUnit; + beads.push({ unitIndex, cellOffset: (isActive ? 0 : 2) + (isClearZone ? 1 : 0) }); + } + return beads; +} + export function renderGrooveGaugeElement( sink: SpriteSink, gauge: Lr2GrooveGaugeElement, percent: number, textures: ReadonlyMap, dst: Lr2DestinationRect, - options: { peakPercent?: number; elapsedMs?: number } = {}, + options: { survivalGauge?: boolean; elapsedMs?: number } = {}, ): void { if (dst.w === 0 || dst.h === 0) { return; @@ -231,25 +272,9 @@ export function renderGrooveGaugeElement( return; } - const totalUnits = 50; - const clearThresholdUnit = Math.round((80 / 100) * totalUnits); - const activeUnits = Math.round((percent / 100) * totalUnits); - // Peak-hold indicator: the bead AT the peak position is painted with the "active" cell variant even when the live - // fill has dropped below it. Skin-agnostic — uses the same 4-cell sprite group, just at the peak's bead position. - const peakPercent = Math.max(0, Math.min(100, options.peakPercent ?? percent)); - const peakIndex = Math.round((peakPercent / 100) * totalUnits) - 1; - for (let unitIndex = 0; unitIndex < totalUnits; unitIndex += 1) { - const isActive = unitIndex < activeUnits; - const isPeakIndicator = !isActive && unitIndex === peakIndex && peakIndex >= activeUnits; - const useActiveCell = isActive || isPeakIndicator; - const isClearZone = unitIndex >= clearThresholdUnit; - // LR2 spec ordering inside each 4-cell frame: offset 0: lit-red (warning zone, below 80 %), offset 1: lit-green - // (clear zone, ≥ 80 %), offset 2: unlit-red (warning zone), offset 3: unlit-green (clear zone). - // i.e. red marks the *below*-clear-threshold beads, green marks the clear zone — matches the IIDX-style "your gauge - // is below 80, you're in danger" color cue. Earlier the frame ordering was correct but the zone mapping was - // inverted; this patch restores `clearZone ? 1 : 0`. - const offsetWithinFrame = (useActiveCell ? 0 : 2) + (isClearZone ? 1 : 0); - const cellIndex = frameIndex * 4 + offsetWithinFrame; + const beads = resolveGrooveGaugeBeads(percent, { survivalGauge: options.survivalGauge }); + for (const { unitIndex, cellOffset } of beads) { + const cellIndex = frameIndex * 4 + cellOffset; const cellX = cellIndex % divx; const cellY = Math.floor(cellIndex / divx); const cellTexture = createCroppedTexture(baseTexture, { @@ -263,7 +288,7 @@ export function renderGrooveGaugeElement( } const sprite = sink.acquireSprite(); sprite.texture = cellTexture; - sprite.label = `gauge-bead[idx=${unitIndex},frame=${frameIndex},cell=${cellIndex}${isPeakIndicator ? ',peak' : ''}]`; + sprite.label = `gauge-bead[idx=${unitIndex},frame=${frameIndex},cell=${cellIndex}]`; sprite.position.set(dst.x + gauge.addX * unitIndex, dst.y + gauge.addY * unitIndex); sprite.width = dst.w; sprite.height = dst.h; diff --git a/packages/player-web/src/scene/lr2/gameplay.ts b/packages/player-web/src/scene/lr2/gameplay.ts index 642ea707..d06af6e7 100644 --- a/packages/player-web/src/scene/lr2/gameplay.ts +++ b/packages/player-web/src/scene/lr2/gameplay.ts @@ -27,7 +27,8 @@ import type { ChartPlayVariant } from '@be-music/player/core/lane-layout'; import type { PlayerInputSignalBus } from '@be-music/player/core/input-signal-bus'; import type { PlayerJudgeComboSignalState, PlayerStateSignals } from '@be-music/player/state-signals'; import type { PlayerUiCommand, PlayerUiFramePayload, PlayerUiSignalBus } from '@be-music/player/core/ui-signal-bus'; -import { createGrooveGaugeState, type GrooveGaugeState } from '@be-music/player/core/groove-gauge'; +import { createGrooveGaugeState, isGrooveGaugeCleared, type GrooveGaugeState } from '@be-music/player/core/groove-gauge'; +import { DEFAULT_POOR_BGA_DISPLAY_SECONDS } from '@be-music/player/core/bga-timeline'; import { createBeatAtSecondsResolverFromTimingResolver, createScrollTimeline, @@ -950,19 +951,6 @@ export class PixiGameplayView { * value 20. */ private gaugeState: GrooveGaugeState = createGrooveGaugeState(0, undefined); - /** - * Peak-hold meter state for the groove gauge. The renderer paints an extra "lit" bead at the highest gauge value seen - * recently — the peak follows the gauge up instantly, holds for a window after the gauge starts dropping, and then - * decays back down to the current value. Mimics LR2's gauge bar (and audio level meters generally) where a thin - * "ghost" indicator marks the recent high above the live fill. - */ - private gaugePeak = 0; - /** `playClock()` ms when the peak was last raised. */ - private gaugePeakUpdatedAt = 0; - /** `playClock()` ms of the most recent peak update tick. */ - private gaugePeakLastTickAt = 0; - private static readonly GAUGE_PEAK_HOLD_MS = 700; - private static readonly GAUGE_PEAK_DECAY_PCT_PER_SEC = 60; /** * FAST / SLOW counts. Incremented on every GREAT or GOOD judgement — PERFECT is "on time" so it doesn't count, * BAD/POOR break combo and aren't tracked here. Mirrors `applyFastSlowForJudge` in `packages/player`'s engine. Reset @@ -1999,12 +1987,6 @@ export class PixiGameplayView { resolved.metadata.total, this.options.gauge ?? 'GROOVE', ); - // Reset the peak-hold meter so the new chart starts with the peak indicator pinned to the gauge's seeded starting - // value (LR2 default 20 %) — the prior play's residual peak would otherwise hang above the gauge for ~1 s after - // restart. - this.gaugePeak = this.gaugeState.current; - this.gaugePeakUpdatedAt = 0; - this.gaugePeakLastTickAt = 0; // Now that the gauge has its starting value (LR2 default 20 %), seed the polyline history so the result-screen // graph starts at the correct origin instead of the first judge's value. this.gaugeHistory.push({ progress: 0, value: this.gaugeState.current }); @@ -3241,42 +3223,6 @@ export class PixiGameplayView { } } - /** - * Updates the gauge peak-hold indicator. Mirrors how an audio level meter's peak indicator behaves: - * - * - Peak follows the gauge value up instantly (peak ≥ current). - * - When the gauge starts dropping the peak holds briefly ({@link GAUGE_PEAK_HOLD_MS}) so the recent maximum stays - * visible — that's the whole point of "peak hold". - * - After the hold expires the peak slides back toward the current value at {@link GAUGE_PEAK_DECAY_PCT_PER_SEC} % - * per second, never dropping below the current. - * - * Pause skips the tick so the meter freezes alongside every other animated element. The early `lastTickAt` seed - * avoids a giant first-frame `dt` integrating against the entire scene-mount window. - */ - private updateGaugePeak(now: number): void { - if (this.gaugePeakLastTickAt === 0) { - this.gaugePeakLastTickAt = now; - return; - } - if (this.paused) { - this.gaugePeakLastTickAt = now; - return; - } - const dt = Math.max(0, (now - this.gaugePeakLastTickAt) / 1000); - this.gaugePeakLastTickAt = now; - const current = this.gaugeState.current; - if (current >= this.gaugePeak) { - this.gaugePeak = current; - this.gaugePeakUpdatedAt = now; - return; - } - if (now - this.gaugePeakUpdatedAt <= PixiGameplayView.GAUGE_PEAK_HOLD_MS) { - return; - } - const decay = PixiGameplayView.GAUGE_PEAK_DECAY_PCT_PER_SEC * dt; - this.gaugePeak = Math.max(current, this.gaugePeak - decay); - } - private tick = (): void => { // Belt-and-suspenders for the rAF-after-dispose race. Even with `app.stop()` removing the renderer's tick listener, // our own `cancelAnimationFrame` can lose to a tick that's already mid-flight when ESC fires. Bailing here keeps @@ -3303,9 +3249,6 @@ export class PixiGameplayView { // Integrate turntable physics before render so the disc's angle reflects this frame's elapsed time. Cheap (constant // work per side) so it doesn't need its own perf bucket. this.updateTurntable(this.playClock()); - // Tick the gauge peak-hold meter so the ghost indicator tracks the gauge bar regardless of whether a judge fired - // this frame (decay needs to run continuously between hits). - this.updateGaugePeak(this.playClock()); this.perf.time('render', () => this.render(seconds)); const report = this.perf.endFrame(() => ({ stage: this.app.stage.children.length, @@ -3523,9 +3466,9 @@ export class PixiGameplayView { score: { ...this.score }, maxCombo: this.maxCombo, gauge: this.gaugeState.current, - // Pass threshold for the LR2 NORMAL gauge is 80 %. Until gauge-type selection lands, every chart is treated as - // NORMAL — see `applyGrooveGaugeJudge` for the same default. - cleared: this.gaugeState.current >= 80, + // Clear threshold is owned by the core gauge model (`clearThreshold` per gauge variant) — keep the comparison + // in `isGrooveGaugeCleared` so core-side threshold changes propagate here without a second edit. + cleared: isGrooveGaugeCleared(this.gaugeState), playSeconds: this.currentSeconds(), song: this.song, gaugeHistory, @@ -3990,7 +3933,7 @@ export class PixiGameplayView { const layerCue = controllingBga.noLayer ? undefined : pickActiveBgaCue(this.bgaTimeline.layer, seconds); const baseKey = baseCue?.bmpKey; const layerKey = layerCue?.bmpKey; - const poorWindowMs = 2000; + const poorWindowMs = DEFAULT_POOR_BGA_DISPLAY_SECONDS * 1000; const inPoorWindow = !controllingBga.noPoor && this.lastPoorAt > 0 && this.playClock() - this.lastPoorAt < poorWindowMs; let poorKey = inPoorWindow ? pickActiveBgaKey(this.bgaTimeline.poor, seconds) : undefined; @@ -4286,7 +4229,9 @@ export class PixiGameplayView { this.textures, this.evaluateElementDst(gauge), { - peakPercent: this.gaugePeak, + // Survival gauges (HARD / DEATH) have no 80 % clear border in LR2 — the whole bar renders in the red cell, + // so suppress the green clear-zone split for them. GROOVE / EASY keep the green ≥ 80 % zone. + survivalGauge: this.gaugeState.type === 'HARD' || this.gaugeState.type === 'DEATH', // Drives the LR2 4-cell × N-frame animation cycle (lit-tip highlight scan). Anchored to the SRC's timer per // spec — `0` is "scene start" which is what most skins use for the gauge. elapsedMs: this.elapsedSinceTimer(gauge.source.timer), diff --git a/packages/player-web/src/skin/beatoraja/runtime-adapter.test.ts b/packages/player-web/src/skin/beatoraja/runtime-adapter.test.ts index a0142320..a15f83e5 100644 --- a/packages/player-web/src/skin/beatoraja/runtime-adapter.test.ts +++ b/packages/player-web/src/skin/beatoraja/runtime-adapter.test.ts @@ -2187,10 +2187,10 @@ describe('BeatorajaRuntimeAdapter — resolveJudgeWindowsMs', () => { expect(adapter.resolveJudgeWindowsMs()).toBeUndefined(); }); - it("resolves IIDX windows from the chart's judge rank", () => { - // RANK 2 (NORMAL) gives the IIDX baseline windows: PG ±16.67, GR ±33.33, GD ±116.67, - // BAD ±250 ms. Resolution path lives in `@be-music/player/core/judge-window` — - // tested there directly. Here we just verify the adapter forwards the call. + it("resolves LR2 windows from the chart's judge rank", () => { + // RANK 2 (NORMAL) gives the LR2 baseline windows: PG ±18, GR ±40, GD ±100, BAD ±200 ms. + // Resolution path lives in `@be-music/player/core/judge-window` — tested there directly. + // Here we just verify the adapter forwards the call. const adapter = new BeatorajaRuntimeAdapter({ chartPlayVariant: '7', baseOps: new Set(), @@ -2205,9 +2205,9 @@ describe('BeatorajaRuntimeAdapter — resolveJudgeWindowsMs', () => { }); const windows = adapter.resolveJudgeWindowsMs(); expect(windows).toBeDefined(); - expect(windows!.pgreat).toBeCloseTo(16.67, 1); - expect(windows!.great).toBeCloseTo(33.33, 1); - expect(windows!.good).toBeCloseTo(116.67, 1); - expect(windows!.bad).toBeCloseTo(250, 1); + expect(windows!.pgreat).toBeCloseTo(18, 1); + expect(windows!.great).toBeCloseTo(40, 1); + expect(windows!.good).toBeCloseTo(100, 1); + expect(windows!.bad).toBeCloseTo(200, 1); }); }); diff --git a/packages/player/CHANGELOG.md b/packages/player/CHANGELOG.md index 303779e2..fd300190 100644 --- a/packages/player/CHANGELOG.md +++ b/packages/player/CHANGELOG.md @@ -1,5 +1,39 @@ # @be-music/player +## 0.5.0 + +### Minor Changes + +- 7bbf052: HARD / EASY / DEATH gauges now follow the LR2 tables (beatoraja GaugeProperty HARD_LR2 / EASY_LR2 / HAZARD_LR2): HARD recovers +0.1/+0.1/+0.05 with damage scaled by the #TOTAL multiplier table and softened ×0.6 under 30%; EASY damages -3.2/-4.8/-1.6 and clears at 80%; DEATH drains -10 on an empty POOR instead of dying and recovers +0.15/+0.06. Survival gauges collapse to 0% (FAILED, no recovery) below 2%, and raw deltas such as mine damage bypass the guts/TOTAL modifiers. +- 811cdfc: Judge windows are now the measured LR2 tables instead of IIDX-baseline linear scaling: #RANK 0/1/2/3 map to ±8/±15/±18/±21ms PGREAT (±24/±30/±40/±60 GREAT, ±40/±60/±100/±120 GOOD), #RANK 4 is treated as NORMAL, the BAD gate is fixed at ±200ms for every rank, and #DEFEXRANK / #EXRANKxx / bmson judge_rank interpolate piecewise-linearly between the rank anchors (lr2oraja JudgeWindowRule.LR2 model) without ever exceeding the BAD gate. +- d0bb321: Mines now follow the LR2 detonation model: a mine explodes while its lane's key is ON and the mine is within the GOOD window of the judge line — covering both presses with a mine in range and holding through a passing mine; a mine passing with the key up is harmless. Explosions only drain the gauge (raw base36 value as the damage percent, matching LR2 / beatoraja, instead of the nanasi value/2 rule) and play #WAV00 — no BAD verdict, no combo break, and mines no longer swallow presses aimed at nearby notes. Mine damage bypasses the HARD guts softening and #TOTAL multiplier; ZZ instantly fails survival gauges. + +### Patch Changes + +- b9922cf: Support the beatoraja bmson long-note type extensions: `info.ln_type` and per-note `t` (1: LN, 2: CN, 3: HCN) are preserved in the IR, round-trip through JSON and bmson output, and drive the player's long-note mode (per-note `t` wins over `info.ln_type`). Charts that specify neither now default to LN (no tail release judgment, matching the LR2-aligned BMS default) instead of always being treated as CN. +- 254e213: Empty POORs (空 POOR) now follow the LR2 trigger condition: a phantom press charges only when a note on the same lane lies within the next 1 second (early side only, fixed window). Presses after a note or on lanes with no upcoming note are harmless keysound presses — previously every phantom press drained the gauge regardless of note proximity. +- ebfdba7: Bump the `node-web-audio-api` runtime dependency from 1.0.9 to 2.0.0 (semver-major). The Node audio sink now runs on the 2.x WebAudio backend with no change to the player's public API. +- ca1012c: Load `node-web-audio-api` through the SEA-aware optional-module loader. In a single executable application the bare-specifier import always fails, which permanently disabled audio playback; the player can now pick the module up from a `node_modules` directory next to the executable (or the working directory) before falling back to silent playback. +- 6ce9173: Missing, undefined, or undecodable `#WAVxx` references are now silent by default, matching LR2 / beatoraja. The synthesized sine fallback tone is opt-in: pass `fallbackToneSeconds` to the audio-renderer APIs or `missingSampleToneSeconds` to the player engine when a debugging tone is wanted. +- 7802f98: Fix four spec-compliance deviations found by the BMS spec audit: + + - HARD / DEATH gauges now report FAILED when they bottom out at 0 % (previously `isGrooveGaugeCleared` treated 0 % as cleared). + - Dynamic `#EXRANKxx` (channel `A0`) values now go through the same `RANK 2 = 100` unit conversion as `#DEFEXRANK`, so `#EXRANK 100` restores exactly the NORMAL judgment width instead of widening it by 4/3. + - bmson `key_channels[].notes[].damage` is now applied as the mine's gauge damage, taking precedence over the BMS `value / 2` rule. + - `#SPEEDxx` now holds the first keyframe's value before its beat (Bemuse reference semantics) instead of ramping linearly from 1.0. + +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [6ce9173] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/audio-renderer@0.2.3 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.4.3 ### Patch Changes diff --git a/packages/player/package.json b/packages/player/package.json index 0900c983..63d25c7e 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/player", - "version": "0.4.3", + "version": "0.5.0", "description": "Core playback engine and shared gameplay helpers for be-music", "license": "MIT", "files": [ @@ -124,6 +124,6 @@ "@be-music/parser": "workspace:*", "@be-music/utils": "workspace:*", "alien-signals": "^3.2.1", - "node-web-audio-api": "^1.0.9" + "node-web-audio-api": "^2.0.0" } } diff --git a/packages/player/src/audio-sink.ts b/packages/player/src/audio-sink.ts index 919c9461..d5de353a 100644 --- a/packages/player/src/audio-sink.ts +++ b/packages/player/src/audio-sink.ts @@ -1,4 +1,5 @@ import { isAbortError, throwIfAborted } from '@be-music/utils/core'; +import { loadOptionalNodeModule } from '@be-music/utils/optional-node-module'; // Browser-compatible cooperative-sleep helper. Mirrors what `node:timers/promises.setTimeout` returned (a Promise // that resolves after `ms` ms) but uses the global `setTimeout` available in both runtimes. Hand-rolled so this @@ -271,8 +272,16 @@ async function loadNodeWebAudioContextConstructor( ): Promise { try { throwIfAborted(signal); - const imported = (await import('node-web-audio-api')) as NodeWebAudioModule; + // In a SEA binary the bare-specifier import always fails; the helper retries from a `node_modules` + // directory next to the executable (or the working directory) before giving up. + const imported = await loadOptionalNodeModule( + 'node-web-audio-api', + () => import('node-web-audio-api') as Promise, + ); throwIfAborted(signal); + if (!imported) { + return undefined; + } const candidate = imported.AudioContext ?? imported.default?.AudioContext ?? imported.default; if (typeof candidate !== 'function') { return undefined; diff --git a/packages/player/src/core/bga-timeline.ts b/packages/player/src/core/bga-timeline.ts index b6bf7a1b..fc45d426 100644 --- a/packages/player/src/core/bga-timeline.ts +++ b/packages/player/src/core/bga-timeline.ts @@ -7,6 +7,13 @@ import { type BeMusicJson, } from '@be-music/json'; +/** + * How long a POOR / miss BGA layer stays visible after a miss before the normal base/layer composite returns. + * Shared by every renderer (TUI compositor, web LR2 scene) so the miss-layer feel is identical across runtimes — + * change it here, not per frontend. + */ +export const DEFAULT_POOR_BGA_DISPLAY_SECONDS = 2; + export interface BgaCue { seconds: number; key?: string; diff --git a/packages/player/src/core/bootstrap.ts b/packages/player/src/core/bootstrap.ts index c1e5710e..05789d08 100644 --- a/packages/player/src/core/bootstrap.ts +++ b/packages/player/src/core/bootstrap.ts @@ -3,6 +3,7 @@ import { normalizeChannel, type BeMusicJson } from '@be-music/json'; import { createGrooveGaugeState, applyGrooveGaugeJudge, + applyGrooveGaugeRawDelta, isGrooveGaugeCleared, type GrooveGaugeJudgeKind, } from './groove-gauge.ts'; @@ -109,10 +110,9 @@ export function createInitialPlayerSummary( syncGrooveGaugeSummary(); }, applyGaugeDelta: (delta: number): void => { - if (!Number.isFinite(delta) || delta === 0) { - return; - } - grooveGauge.current = Math.max(grooveGauge.min, Math.min(grooveGauge.max, grooveGauge.current + delta)); + // Raw deltas (mine damage, HCN drain) go through the gauge module's survival rules — LR2 mine damage bypasses + // the per-judge tables / guts / #TOTAL scaling but still kills a survival gauge that bottoms out. + applyGrooveGaugeRawDelta(grooveGauge, delta); syncGrooveGaugeSummary(); }, }; diff --git a/packages/player/src/core/engine.ts b/packages/player/src/core/engine.ts index 189a5baf..f0e95796 100644 --- a/packages/player/src/core/engine.ts +++ b/packages/player/src/core/engine.ts @@ -7,6 +7,7 @@ import { isPlayLaneSoundChannel, parseBmsDynamicVolumeGain, sortEvents, + usesMonophonicWavPlayback, } from '@be-music/chart'; // Hand-rolled polyfills replace what was previously imported from `node:path` / `node:timers/promises`. Keeping the // engine free of `node:`-prefixed imports lets the same module run unchanged in the browser (Phase 4 of the @@ -21,7 +22,7 @@ const basename = (path: string): string => { return lastSep === -1 ? trimmed : trimmed.slice(lastSep + 1); }; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -import { floatToInt16, throwIfAborted } from '@be-music/utils/core'; +import { findFirstIndexNumberAtOrAfter, floatToInt16, throwIfAborted } from '@be-music/utils/core'; import type { LogEntry, LogLevel } from '@be-music/utils/log'; import { type BeMusicEvent, @@ -75,7 +76,7 @@ import { type JudgeKind, } from './scoring.ts'; import { type GrooveGaugeJudgeKind, type GrooveGaugeType } from './groove-gauge.ts'; -import { resolveBmsJudgeWindowsMsForPercent, resolveJudgeWindowsMs } from './judge-window.ts'; +import { resolveBmsJudgeWindowsMsForExRankValue, resolveJudgeWindowsMs } from './judge-window.ts'; import { createBeatAtSecondsResolverFromTimingResolver, createBpmTimeline, @@ -161,6 +162,12 @@ export interface PlayerOptions { bgmVolume?: number; playVolume?: number; audioBaseDir?: string; + /** + * Debug aid — synthesizes a short sine tone for `#WAVxx` references whose file is missing or fails to decode. + * The spec-compliant default is silence (LR2 / beatoraja play nothing for a broken keysound reference); enable + * this only in test rigs / chart debugging where hearing that a trigger fired matters. + */ + missingSampleToneSeconds?: number; audioTailSeconds?: number; audioOffsetMs?: number; audioHeadPaddingMs?: number; @@ -492,6 +499,12 @@ const DEBUG_ACTIVE_AUDIO_FALLBACK_SECONDS = 0.18; const DEBUG_ACTIVE_AUDIO_SAMPLE_RATE = 44_100; const RUNTIME_AUDIO_SAMPLE_RATE = 44_100; const REALTIME_AUDIO_TRIGGER_EPSILON_SECONDS = 1e-6; +/** + * LR2 empty-POOR (空POOR) early window — a phantom press only charges while a note on the lane lies within the next + * second; presses after a note never charge (lr2oraja `JudgeProperty` LR2 miss window `{0, 1000000}`µs, fixed + * regardless of rank / EXRANK). + */ +const LR2_EMPTY_POOR_EARLY_WINDOW_SECONDS = 1; const DEFAULT_COMPRESSOR_THRESHOLD_DB = -12; const DEFAULT_COMPRESSOR_RATIO = 2.5; const DEFAULT_COMPRESSOR_ATTACK_MS = 8; @@ -856,19 +869,32 @@ function resolveLandmineExplosionEvent( } function resolveLandmineGaugeEffect( - landmineEvent: Pick, + landmineEvent: Pick, base: 36 | 62 = 36, ): { objectValue: string; damage: number; gaugeDelta: number; } { - // Mine damage encodes the value in base-36 regardless of the chart's `#BASE` setting (the damage formula `value/2` is - // a BMS-spec constant, not an indexed-resource lookup), so the ID is normalized under the chart's base only to keep - // the returned `objectValue` in sync with the rest of the resource-key reporting. Mine charts that opt into base-62 - // and use lowercase mine values will surface them verbatim here; the BASE36-pattern guard below still controls - // whether the value is interpreted numerically. + // Mine damage encodes the value in base-36 regardless of the chart's `#BASE` setting (the damage encoding is a + // chart-format constant, not an indexed-resource lookup), so the ID is normalized under the chart's base only to + // keep the returned `objectValue` in sync with the rest of the resource-key reporting. LR2 and beatoraja both + // interpret the value DIRECTLY as the gauge-damage percentage (losak's LR2 mine writeup; jbms-parser passes the raw + // base-36 value into `MineNote`) — the nanasi-era `value / 2` rule in hitkey's memo is a different lineage and is + // NOT what LR2 does. `ZZ` (= 1295) therefore wipes any gauge: survival gauges die instantly, GROOVE / EASY hit + // their 2 % floor. const objectValue = normalizeObjectKey(landmineEvent.value, base); + // bmson `key_channels[].notes[].damage` is an explicit per-mine gauge percentage; when present it wins over the BMS + // `value / 2` rule because the event value there is the WAV slot, not a damage encoding. `damage: 0` is a valid + // authored value (a no-damage decoration mine), so the guard checks finiteness rather than truthiness. + const bmsonDamage = landmineEvent.bmson?.damage; + if (typeof bmsonDamage === 'number' && Number.isFinite(bmsonDamage) && bmsonDamage >= 0) { + return { + objectValue, + damage: bmsonDamage, + gaugeDelta: -bmsonDamage, + }; + } if (!BASE36_OBJECT_KEY_PATTERN.test(objectValue)) { return { objectValue, @@ -876,7 +902,7 @@ function resolveLandmineGaugeEffect( gaugeDelta: -DEFAULT_LANDMINE_GAUGE_DAMAGE, }; } - const parsedDamage = Number.parseInt(objectValue, 36) / 2; + const parsedDamage = Number.parseInt(objectValue, 36); if (!Number.isFinite(parsedDamage) || parsedDamage <= 0) { return { objectValue, @@ -1305,13 +1331,13 @@ function createNoTuiPlaybackEventTracer(params: { .filter((event): event is NoTuiScheduledPlaybackEvent => event !== undefined); const judgeRankEvents = collectDynamicBmsJudgeRankChanges(json, resolver) .map((change) => { - const badWindow = resolveBmsJudgeWindowsMsForPercent(change.rankPercent, judgeWindowMs).bad; + const badWindow = resolveBmsJudgeWindowsMsForExRankValue(change.exRankValue, judgeWindowMs).bad; return createScheduledPlaybackEvent( change.seconds, nextOrder++, createRuntimeEventLine('judge-rank-change', [ ['time', formatSeconds(change.seconds)], - ['rank', formatLoggedNumericValue(change.rankPercent)], + ['rank', formatLoggedNumericValue(change.exRankValue)], ['bad', `${formatLoggedNumericValue(badWindow)}ms`], ]), ); @@ -1647,7 +1673,8 @@ export function formatRandomPatternSummary(randomPatterns: ReadonlyArray maxBadWindowMs) { maxBadWindowMs = dynamicBadWindowMs; } @@ -2565,6 +2592,32 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) const autoScratchNotes = autoScratchEnabled ? scorableNotes.filter((note) => scratchPlayableChannels.has(note.channel)) : []; + // LR2 empty-POOR reference times — a phantom press registers as 空POOR only while a note on that lane lies within + // the next second (lr2oraja `JudgeProperty` LR2 miss window `{0, 1000000}`µs, early side only). Per-channel sorted + // note times let the press handler binary-search the next upcoming note; judged notes stay valid references (LR2's + // `MissCondition.ALWAYS` keeps mashing in front of an already-judged note producing 空POORs). + const scorableNoteSecondsByChannel = new Map(); + for (const note of scorableNotes) { + const noteTimes = scorableNoteSecondsByChannel.get(note.channel); + if (noteTimes) { + noteTimes.push(note.seconds); + } else { + scorableNoteSecondsByChannel.set(note.channel, [note.seconds]); + } + } + const hasEmptyPoorReferenceNote = (channels: ReadonlySet, nowSec: number): boolean => { + for (const channel of channels) { + const noteTimes = scorableNoteSecondsByChannel.get(channel); + if (!noteTimes) { + continue; + } + const index = findFirstIndexNumberAtOrAfter(noteTimes, nowSec); + if (index < noteTimes.length && noteTimes[index]! - nowSec <= LR2_EMPTY_POOR_EARLY_WINDOW_SECONDS) { + return true; + } + } + return false; + }; let autoScratchCursor = 0; let scorableMissCursor = 0; let landmineExpireCursor = 0; @@ -2603,19 +2656,84 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) return true; }; - const markExpiredLandmines = (referenceSeconds: number): void => { + const lastLanePressMsByChannel = new Map(); + const isLandmineChannelHeld = (channel: string, nowMs: number): boolean => { + if (activeKittyPressedChannels.has(channel)) { + return true; + } + // Non-kitty input carries no release events, so "held" is approximated with the same short grace window the LN + // hold logic uses for terminal key repeat. + const lastPressMs = lastLanePressMsByChannel.get(channel); + return lastPressMs !== undefined && nowMs - lastPressMs <= LONG_NOTE_REPEAT_HOLD_GRACE_MS; + }; + + const detonateLandmine = (landmine: TimedLandmineNote, nowSec: number): void => { + if (!markLandmineJudged(landmine)) { + return; + } + const landmineGaugeEffect = resolveLandmineGaugeEffect(landmine.event, idBase); + const landmineExplosionEvent = resolveLandmineExplosionEvent(landmine.event, wavResources); + if (landmineExplosionEvent) { + if (!uiEnabled) { + writePlayableSampleTriggerEventLog( + writeOutput, + landmineExplosionEvent, + nowSec, + wavResources, + 'mine-hit', + landmine.channel, + idBase, + ); + } + audioSession?.triggerEvent?.(landmineExplosionEvent); + } + // LR2 — a mine hit drains the gauge and plays the explosion sample, nothing else: no verdict, no combo break, no + // judge-counter change (beatoraja's JudgeManager likewise only calls `gauge.addValue`). The raw-delta path also + // bypasses the HARD guts softening and #TOTAL damage multiplier, matching `GrooveGauge.addValue`. + applyLoggedGaugeDelta(nowSec, landmineGaugeEffect.gaugeDelta, 'mine-hit'); + if (!uiEnabled) { + writeRuntimeEventLog(writeOutput, 'mine-hit', [ + ['time', formatSeconds(nowSec)], + ['channel', landmine.channel], + ['value', landmineGaugeEffect.objectValue], + ['damage', landmineGaugeEffect.damage], + ]); + } + }; + + /** + * LR2 mine model (losak's LR2 writeup, confirmed by otlovers): a mine explodes while its lane's key is ON and the + * mine sits within the GOOD window of the judge line — covering both "press while a mine is in range" and "hold + * through a passing mine". Mines that leave the window with the key up are retired silently (passing an + * un-pressed mine is harmless). Runs on every frame tick and on every press dispatch. + */ + const processLandminePassage = (nowSec: number, nowMs: number): void => { + const goodWindowSeconds = judgeWindows.good / 1000; while (landmineExpireCursor < landmineNotes.length) { const landmine = landmineNotes[landmineExpireCursor]!; if (landmine.judged) { landmineExpireCursor += 1; continue; } - if (referenceSeconds - landmine.seconds <= badWindowSeconds) { + if (nowSec - landmine.seconds <= goodWindowSeconds) { break; } markLandmineJudged(landmine); landmineExpireCursor += 1; } + for (let index = landmineExpireCursor; index < landmineNotes.length; index += 1) { + const landmine = landmineNotes[index]!; + if (landmine.seconds - nowSec > goodWindowSeconds) { + break; + } + if (landmine.judged) { + continue; + } + if (!isLandmineChannelHeld(landmine.channel, nowMs)) { + continue; + } + detonateLandmine(landmine, nowSec); + } }; const markExpiredInvisibleNotes = (referenceSeconds: number): void => { @@ -2640,7 +2758,7 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) if (change.seconds > safeReferenceSeconds) { break; } - judgeWindows = resolveBmsJudgeWindowsMsForPercent(change.rankPercent, options.judgeWindowMs); + judgeWindows = resolveBmsJudgeWindowsMsForExRankValue(change.exRankValue, options.judgeWindowMs); badWindowMs = judgeWindows.bad; badWindowSeconds = badWindowMs / 1000; dynamicJudgeRankCursor += 1; @@ -2973,76 +3091,16 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) refreshedHold = true; } - const candidate = findBestCandidate(scorableNotes, candidateChannels, nowSec, badWindowSeconds); - const landmineCandidate = findBestCandidate(landmineNotes, candidateChannels, nowSec, badWindowSeconds); - const candidateDelta = candidate ? Math.abs(candidate.seconds - nowSec) : Number.POSITIVE_INFINITY; - const landmineDelta = landmineCandidate ? Math.abs(landmineCandidate.seconds - nowSec) : Number.POSITIVE_INFINITY; - - if (landmineCandidate && landmineDelta <= candidateDelta) { - if (!markLandmineJudged(landmineCandidate)) { - return; - } - const landmineGaugeEffect = resolveLandmineGaugeEffect(landmineCandidate.event, idBase); - const landmineExplosionEvent = resolveLandmineExplosionEvent(landmineCandidate.event, wavResources); - // **Active-LN guard** — mirrors upstream `JudgeManager.java:253-259`. When a mine note is - // passed while the same lane's LN is currently being held, the engine treats it as a - // SILENT gauge drain: the damage is applied, the keysound plays at key volume, but NO - // verdict / combo reset is emitted. This preserves HCN chart authoring intent where - // the artist deliberately routes a mine column through an active hold — penalizing the - // player only via gauge pressure, not by breaking their combo. The previous TS impl - // emitted a full BAD which both reset the combo AND cost an exScore slot (since `total` - // doesn't shrink), making any HCN with mines-during-hold practically unclear-able. - const heldLongNote = activeLongNotesByChannel.get(landmineCandidate.channel); - const silentDuringHold = heldLongNote !== undefined; - if (landmineExplosionEvent) { - if (!uiEnabled) { - // The sample trigger log uses the same `'mine-hit'` kind for both branches — - // it's the audio-trigger record, and the keysound plays at the same volume in - // both cases. The verdict-vs-silent distinction is logged via the `mine-hit` - // / `mine-hit-during-hold` runtime-event-log entry below. - writePlayableSampleTriggerEventLog( - writeOutput, - landmineExplosionEvent, - nowSec, - wavResources, - 'mine-hit', - landmineCandidate.channel, - idBase, - ); - } - audioSession?.triggerEvent?.(landmineExplosionEvent); - } - if (silentDuringHold) { - // Gauge damage only — no verdict / combo / score change. Matches upstream's - // `gauge.addValue(-mnote.getDamage())` without an accompanying `updateMicro` call. - applyLoggedGaugeDelta(nowSec, landmineGaugeEffect.gaugeDelta, 'mine-hit-during-hold'); - if (!uiEnabled) { - writeRuntimeEventLog(writeOutput, 'mine-hit-during-hold', [ - ['time', formatSeconds(nowSec)], - ['channel', landmineCandidate.channel], - ['value', landmineGaugeEffect.objectValue], - ['damage', landmineGaugeEffect.damage], - ['deltaMs', Math.round(landmineDelta * 1000)], - ]); - } - return; - } - applyJudgeToSummary(summary, 'BAD', scoreTracker); - applyLoggedGaugeDelta(nowSec, landmineGaugeEffect.gaugeDelta, 'mine-hit'); - setLoggedCombo(nowSec, 0, 'mine-hit', 'BAD', landmineCandidate.channel); - if (!uiEnabled) { - writeRuntimeEventLog(writeOutput, 'mine-hit', [ - ['time', formatSeconds(nowSec)], - ['channel', landmineCandidate.channel], - ['value', landmineGaugeEffect.objectValue], - ['damage', landmineGaugeEffect.damage], - ['deltaMs', Math.round(landmineDelta * 1000)], - ]); - } else { - activeStateSignals?.publishJudgeCombo('BAD', combo, landmineCandidate.channel); - } - return; + // LR2 mine model — the press itself counts as "key ON": record the press instant for the non-kitty hold + // approximation, then let the shared passage processor detonate any mine currently inside the GOOD window on + // these lanes. Detonation never consumes the press: the regular note judgment below still runs, so a mine close + // to a real note no longer swallows the player's input. + for (const channel of candidateChannels) { + lastLanePressMsByChannel.set(channel, nowMs); } + processLandminePassage(nowSec, nowMs); + + const candidate = findBestCandidate(scorableNotes, candidateChannels, nowSec, badWindowSeconds); if (!candidate) { if (refreshedHold) { @@ -3072,10 +3130,16 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) return; } } - // LR2-compatible empty POOR (kara-poor / 空POOR): phantom press with no candidate and no benign explanation. Apply the gauge - // delta (GROOVE / HARD -2, EASY -1, DEATH -100 — see `applyGrooveGaugeJudge('EMPTY_POOR')`) and fire the POOR - // BGA, but DO NOT break combo or increment `summary.poor`. Real LR2 behavior: NORMAL / EASY make this nearly - // harmless; HARD / DEATH actually drain. + if (!hasEmptyPoorReferenceNote(candidateChannels, nowSec)) { + // LR2 — a press with no note on the lane within the next second is harmless: the keysound (fallback above) + // plays and nothing else happens. 空POOR only ever fires on the EARLY side of a note; presses after a note + // never charge. + return; + } + // LR2-compatible empty POOR (kara-poor / 空POOR): phantom press in front of an upcoming note (within 1 s, + // outside its judgable window). Apply the gauge delta (GROOVE -2, HARD -2 × TOTAL modifier, EASY -1.6, + // DEATH -10 — see `applyGrooveGaugeJudge('EMPTY_POOR')`) and fire the POOR BGA, but DO NOT break combo or + // increment `summary.poor`. Repeatable per note (LR2's MissCondition.ALWAYS). applyLoggedGaugeJudge(nowSec, 'EMPTY_POOR', 'empty-poor'); uiSignals.pushCommand({ kind: 'trigger-poor-bga', seconds: nowSec }); if (!uiEnabled) { @@ -3521,7 +3585,7 @@ export async function manualPlay(json: BeMusicJson, options: PlayerOptions = {}) applyExpiredScorableJudgements(nowSec); publishUiFrame(nowSec, nowBeat); - markExpiredLandmines(nowSec); + processLandminePassage(nowSec, nowMs); markExpiredInvisibleNotes(nowSec); const safeNowSeconds = Math.max(0, nowSec) + REALTIME_AUDIO_TRIGGER_EPSILON_SECONDS; @@ -3871,7 +3935,7 @@ async function createAudioSessionIfEnabled( if (offsetFrames >= endPosition) { return; } - if (json.sourceFormat === 'bms') { + if (usesMonophonicWavPlayback(json)) { removeActiveVoicesInPlace(activeVoices, (voice) => voice.sampleKey === normalized); } if (playback?.sliceId && activeVoices.some((voice) => voice.sliceId === playback.sliceId)) { @@ -3987,7 +4051,7 @@ async function createDebugActiveAudioEstimator( .filter((window) => window.endSeconds > window.startSeconds) .sort((left, right) => left.startSeconds - right.startSeconds); - if (json.sourceFormat === 'bms') { + if (usesMonophonicWavPlayback(json)) { const latestBySampleKey = new Map(); for (let index = 0; index < windows.length; index += 1) { const window = windows[index]!; @@ -4453,7 +4517,12 @@ async function buildRuntimeSampleMap( baseDir: options.audioBaseDir ?? process.cwd(), sampleRate, gain: chartWavGain, - fallbackToneSeconds: 0.06, + fallbackToneSeconds: + typeof options.missingSampleToneSeconds === 'number' && + Number.isFinite(options.missingSampleToneSeconds) && + options.missingSampleToneSeconds > 0 + ? options.missingSampleToneSeconds + : 0, signal, base: idBase, }); diff --git a/packages/player/src/core/groove-gauge.test.ts b/packages/player/src/core/groove-gauge.test.ts index 614ad1a3..df0f6cd5 100644 --- a/packages/player/src/core/groove-gauge.test.ts +++ b/packages/player/src/core/groove-gauge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { applyGrooveGaugeJudge, + applyGrooveGaugeRawDelta, createGrooveGaugeState, isGrooveGaugeCleared, LR2_GROOVE_GAUGE_DEFAULT_TOTAL, @@ -51,4 +52,92 @@ describe('groove gauge', () => { expect(highGauge.current).toBe(100); expect(isGrooveGaugeCleared(highGauge)).toBe(true); }); + + test('HARD gauge uses the LR2 fixed recovery and TOTAL-scaled damage', () => { + // TOTAL 240 → damage multiplier 1.0 (the top row of the LR2 fix1 table). + const gauge = createGrooveGaugeState(20, 240, 'HARD'); + expect(gauge.current).toBe(100); + + expect(applyGrooveGaugeJudge(gauge, 'PERFECT')).toBeCloseTo(0.1, 9); // clamped at 100 + expect(applyGrooveGaugeJudge(gauge, 'BAD')).toBeCloseTo(-6, 9); + expect(applyGrooveGaugeJudge(gauge, 'POOR')).toBeCloseTo(-10, 9); + expect(applyGrooveGaugeJudge(gauge, 'EMPTY_POOR')).toBeCloseTo(-2, 9); + expect(gauge.current).toBeCloseTo(82, 9); + expect(applyGrooveGaugeJudge(gauge, 'GOOD')).toBeCloseTo(0.05, 9); + expect(gauge.current).toBeCloseTo(82.05, 9); + }); + + test('HARD gauge damage scales with #TOTAL (LR2 fix1 table)', () => { + // Default TOTAL 160 → ×2.0; TOTAL 100 (< 120) → ×10. + const total160 = createGrooveGaugeState(20, 160, 'HARD'); + expect(applyGrooveGaugeJudge(total160, 'POOR')).toBeCloseTo(-20, 9); + + const total100 = createGrooveGaugeState(20, 100, 'HARD'); + expect(applyGrooveGaugeJudge(total100, 'POOR')).toBeCloseTo(-100, 9); + expect(total100.current).toBe(0); + expect(isGrooveGaugeCleared(total100)).toBe(false); + + // Recovery is TOTAL-independent. + const recovery = createGrooveGaugeState(20, 100, 'HARD'); + applyGrooveGaugeJudge(recovery, 'BAD'); // -60, current 40 + expect(applyGrooveGaugeJudge(recovery, 'PERFECT')).toBeCloseTo(0.1, 9); + }); + + test('HARD gauge softens damage by 0.6 below 30 %', () => { + const gauge = createGrooveGaugeState(20, 240, 'HARD'); + gauge.current = 30; + expect(applyGrooveGaugeJudge(gauge, 'POOR')).toBeCloseTo(-10, 9); // exactly 30 — strict less-than, no guts + expect(gauge.current).toBeCloseTo(20, 9); + expect(applyGrooveGaugeJudge(gauge, 'POOR')).toBeCloseTo(-6, 9); // 20 < 30 — ×0.6 + expect(gauge.current).toBeCloseTo(14, 9); + }); + + test('survival gauges collapse below 2 % and never recover from 0', () => { + const gauge = createGrooveGaugeState(20, 240, 'HARD'); + gauge.current = 7; + applyGrooveGaugeJudge(gauge, 'POOR'); // guts: -6 → 1 → below the 2 % death threshold → 0 + expect(gauge.current).toBe(0); + expect(isGrooveGaugeCleared(gauge)).toBe(false); + + expect(applyGrooveGaugeJudge(gauge, 'PERFECT')).toBe(0); // dead gauges never recover + expect(gauge.current).toBe(0); + }); + + test('DEATH gauge follows the LR2 HAZARD table', () => { + const gauge = createGrooveGaugeState(20, 160, 'DEATH'); + expect(applyGrooveGaugeJudge(gauge, 'EMPTY_POOR')).toBeCloseTo(-10, 9); // drains, not instant death + expect(gauge.current).toBeCloseTo(90, 9); + expect(applyGrooveGaugeJudge(gauge, 'GREAT')).toBeCloseTo(0.06, 9); + expect(applyGrooveGaugeJudge(gauge, 'GOOD')).toBe(0); + + applyGrooveGaugeJudge(gauge, 'POOR'); // -100 → instant death + expect(gauge.current).toBe(0); + expect(isGrooveGaugeCleared(gauge)).toBe(false); + }); + + test('EASY gauge uses the LR2 1.2× gains / 0.8× damage and clears at 80 %', () => { + const gauge = createGrooveGaugeState(400, 200, 'EASY'); // a = 0.5 + expect(gauge.clearThreshold).toBe(80); + + expect(applyGrooveGaugeJudge(gauge, 'PERFECT')).toBeCloseTo(0.6, 9); // 1.2a + expect(applyGrooveGaugeJudge(gauge, 'GOOD')).toBeCloseTo(0.3, 9); // 0.6a + expect(applyGrooveGaugeJudge(gauge, 'BAD')).toBeCloseTo(-3.2, 9); + expect(applyGrooveGaugeJudge(gauge, 'POOR')).toBeCloseTo(-4.8, 9); + expect(applyGrooveGaugeJudge(gauge, 'EMPTY_POOR')).toBeCloseTo(-1.6, 9); + }); + + test('applyGrooveGaugeRawDelta bypasses guts / TOTAL scaling but keeps the survival life rules', () => { + const hard = createGrooveGaugeState(20, 100, 'HARD'); // TOTAL 100 would mean ×10 on judges — raw deltas ignore it + hard.current = 25; // below the guts threshold — raw deltas ignore that too + expect(applyGrooveGaugeRawDelta(hard, -10)).toBe(-10); + expect(hard.current).toBeCloseTo(15, 9); + + applyGrooveGaugeRawDelta(hard, -14); // 1 → collapses below the 2 % death threshold + expect(hard.current).toBe(0); + expect(applyGrooveGaugeRawDelta(hard, 5)).toBe(0); // dead gauges never recover + + const groove = createGrooveGaugeState(20, 160, 'GROOVE'); + applyGrooveGaugeRawDelta(groove, -50); + expect(groove.current).toBe(2); // GROOVE keeps its 2 % soft floor + }); }); diff --git a/packages/player/src/core/groove-gauge.ts b/packages/player/src/core/groove-gauge.ts index 74146ee7..005ec2bc 100644 --- a/packages/player/src/core/groove-gauge.ts +++ b/packages/player/src/core/groove-gauge.ts @@ -5,6 +5,27 @@ export const LR2_GROOVE_GAUGE_INITIAL = 20; export const LR2_GROOVE_GAUGE_MIN = 2; export const LR2_GROOVE_GAUGE_MAX = 100; export const LR2_GROOVE_GAUGE_CLEAR_THRESHOLD = 80; +/** + * Survival gauges (HARD / DEATH) collapse to 0 % once they drop below 2 % — LR2 fails the stage at that point + * (iidx.org "you get a stage fail at 2%"; lr2oraja `GaugeProperty` `death = 2`). The gauge display granularity in + * LR2 is 2 %, so "below 2 %" and "reads 0 %" coincide. + */ +const LR2_SURVIVAL_GAUGE_DEATH_THRESHOLD = 2; +/** + * LR2 HARD damage softening — below 30 % the damage is multiplied by 0.6 (single stage, unlike beatoraja's + * five-stage guts). Threshold is a strict less-than per beatoraja master's `HARD_LR2` guts `{30, 0.6}`; note + * lr2oraja ≥0.8.3 moved it to `< 32` ("display 30 % = internal 32 %"), which lacks first-party measurement — we + * follow beatoraja master until that's confirmed. + */ +const LR2_HARD_GUTS_THRESHOLD = 30; +const LR2_HARD_GUTS_MULTIPLIER = 0.6; +/** + * LR2 HARD damage scaling by #TOTAL (beatoraja `GrooveGauge.MODIFY_DAMAGE` `fix1total` / `fix1table`): low-TOTAL + * charts take disproportionally larger HARD damage. Applies to damage only — recovery is TOTAL-independent. The + * default #TOTAL 160 lands on ×2.0, which is the familiar "LR2 HARD hits twice as hard as beatoraja" behavior. + */ +const LR2_HARD_DAMAGE_TOTAL_THRESHOLDS = [240, 230, 210, 200, 180, 160, 150, 130, 120] as const; +const LR2_HARD_DAMAGE_TOTAL_MULTIPLIERS = [1.0, 1.11, 1.25, 1.5, 1.666, 2.0, 2.5, 3.333, 5.0, 10.0] as const; export type GrooveGaugeJudgeKind = JudgeKind | 'EMPTY_POOR'; @@ -49,17 +70,53 @@ export function createGrooveGaugeState( } export function applyGrooveGaugeJudge(state: GrooveGaugeState, judge: GrooveGaugeJudgeKind): number { - const delta = resolveGrooveGaugeDelta(state, judge); + if (isSurvivalGaugeType(state.type) && state.current <= 0) { + // Dead survival gauges never recover (beatoraja `GrooveGauge.setValue` guards on `value > 0`). + return 0; + } + let delta = resolveGrooveGaugeDelta(state, judge); + if (state.type === 'HARD' && delta < 0 && state.current < LR2_HARD_GUTS_THRESHOLD) { + delta *= LR2_HARD_GUTS_MULTIPLIER; + } state.current = clampGrooveGauge(state.current + delta, state.min, state.max); - // DEATH gauge — any non-positive verdict zeroes the gauge so subsequent renders / fail-detection see an instant - // flat-line. - if (state.type === 'DEATH' && (judge === 'POOR' || judge === 'BAD' || judge === 'EMPTY_POOR')) { - state.current = state.min; + collapseDeadSurvivalGauge(state); + return delta; +} + +/** + * Applies a raw percentage delta (mine damage, HCN hold drain / recovery) with the survival-gauge life rules but + * WITHOUT the per-judge tables, HARD guts softening, or the #TOTAL damage multiplier — LR2 mine damage bypasses all + * of those (beatoraja `JudgeManager` calls `gauge.addValue()` directly). + */ +export function applyGrooveGaugeRawDelta(state: GrooveGaugeState, delta: number): number { + if (!Number.isFinite(delta) || delta === 0) { + return 0; } + if (isSurvivalGaugeType(state.type) && state.current <= 0) { + return 0; + } + state.current = clampGrooveGauge(state.current + delta, state.min, state.max); + collapseDeadSurvivalGauge(state); return delta; } +function isSurvivalGaugeType(type: GrooveGaugeType): boolean { + return type === 'HARD' || type === 'DEATH'; +} + +function collapseDeadSurvivalGauge(state: GrooveGaugeState): void { + if (isSurvivalGaugeType(state.type) && state.current < LR2_SURVIVAL_GAUGE_DEATH_THRESHOLD) { + state.current = state.min; + } +} + export function isGrooveGaugeCleared(state: GrooveGaugeState): boolean { + // Survival gauges (HARD / DEATH) fail the run the moment they bottom out at 0 % — playback continues to the end of + // the chart, but the result is FAILED. The epsilon-tolerant threshold comparison below would read 0 >= 0 as cleared, + // so they get a strict "still alive" check instead. + if (state.type === 'HARD' || state.type === 'DEATH') { + return state.current > 0; + } return state.current + 1e-9 >= state.clearThreshold; } @@ -82,9 +139,11 @@ function resolveGrooveGaugeMin(type: GrooveGaugeType): number { } function resolveGrooveGaugeClearThreshold(type: GrooveGaugeType): number { - if (type === 'EASY') return 60; - // HARD / DEATH "clear" if you survive past 0 — any positive gauge at end-of-chart counts. We reuse 0 + epsilon as the - // threshold so `isGrooveGaugeCleared` uses the same comparison. + // LR2's EASY clears at 80 % just like GROOVE (beatoraja `EASY_LR2` border = 80; the 60 % border belongs to + // ASSIST EASY, which this engine does not model). + if (type === 'EASY') return LR2_GROOVE_GAUGE_CLEAR_THRESHOLD; + // HARD / DEATH "clear" if you survive past 0 — any positive gauge at end-of-chart counts, exactly 0 % is FAILED. + // `isGrooveGaugeCleared` enforces the strict > 0 comparison for these types; the 0 here is the display threshold. if (type === 'HARD' || type === 'DEATH') return 0; return LR2_GROOVE_GAUGE_CLEAR_THRESHOLD; } @@ -113,31 +172,43 @@ function resolveGrooveGaugeDeltaNormal(state: GrooveGaugeState, judge: GrooveGau return baseGain; } -function resolveHardGaugeDelta(_state: GrooveGaugeState, judge: GrooveGaugeJudgeKind): number { - // HARD penalties are flat percentages — independent of TOTAL — because the gauge starts at 100 % and the penalty - // model is "how many BADs / POORs can you survive". Approximates LR2's canonical values; re-tune if score-history - // calibration says otherwise. - if (judge === 'BAD') return -6; - if (judge === 'POOR') return -10; - if (judge === 'EMPTY_POOR') return -2; - if (judge === 'GOOD') return 0.16; - // PERFECT / GREAT — small recovery nudge, capped by the 100 % ceiling. - return 0.16; +function resolveHardGaugeDelta(state: GrooveGaugeState, judge: GrooveGaugeJudgeKind): number { + // LR2 HARD (beatoraja `HARD_LR2` {0.1, 0.1, 0.05, -6, -10, -2}): recovery is a fixed percentage independent of + // #TOTAL; damage scales with the low-TOTAL multiplier table. + const damageMultiplier = resolveHardDamageTotalMultiplier(state.effectiveTotal); + if (judge === 'BAD') return -6 * damageMultiplier; + if (judge === 'POOR') return -10 * damageMultiplier; + if (judge === 'EMPTY_POOR') return -2 * damageMultiplier; + if (judge === 'GOOD') return 0.05; + // PERFECT / GREAT. + return 0.1; +} + +function resolveHardDamageTotalMultiplier(totalValue: number): number { + for (let index = 0; index < LR2_HARD_DAMAGE_TOTAL_THRESHOLDS.length; index += 1) { + if (totalValue >= LR2_HARD_DAMAGE_TOTAL_THRESHOLDS[index]!) { + return LR2_HARD_DAMAGE_TOTAL_MULTIPLIERS[index]!; + } + } + return LR2_HARD_DAMAGE_TOTAL_MULTIPLIERS.at(-1)!; } function resolveDeathGaugeDelta(_state: GrooveGaugeState, judge: GrooveGaugeJudgeKind): number { - // DEATH — any miss / poor zeroes the gauge (handled by the post-clamp nudge in `applyGrooveGaugeJudge`). Hits give a - // tiny token gain so the polyline reads as "alive" while the chart still has playable notes. - if (judge === 'BAD' || judge === 'POOR' || judge === 'EMPTY_POOR') return -100; + // LR2 HAZARD (beatoraja `HAZARD_LR2` {0.15, 0.06, 0, -100, -100, -10}): any BAD / missed POOR is instant death; + // an empty POOR drains 10 % rather than killing outright. No guts and no #TOTAL damage scaling. + if (judge === 'BAD' || judge === 'POOR') return -100; + if (judge === 'EMPTY_POOR') return -10; if (judge === 'GOOD') return 0; - return 0.16; + if (judge === 'GREAT') return 0.06; + return 0.15; } function resolveEasyGaugeDelta(state: GrooveGaugeState, judge: GrooveGaugeJudgeKind): number { - // EASY — gentler than GROOVE. ~1.2× baseline gains, ~50 % smaller miss penalties. - if (judge === 'BAD') return -2; - if (judge === 'POOR') return -3; - if (judge === 'EMPTY_POOR') return -1; + // LR2 EASY (beatoraja `EASY_LR2` {1.2, 1.2, 0.6, -3.2, -4.8, -1.6}): gains are 1.2× GROOVE, damage is 0.8× + // GROOVE. The clear threshold stays at 80 % — LR2's EASY is gentler, not lower-bar. + if (judge === 'BAD') return -3.2; + if (judge === 'POOR') return -4.8; + if (judge === 'EMPTY_POOR') return -1.6; if (state.noteCount <= 0) return 0; const baseGain = (state.effectiveTotal / state.noteCount) * 1.2; if (judge === 'GOOD') return baseGain / 2; diff --git a/packages/player/src/core/judge-window.test.ts b/packages/player/src/core/judge-window.test.ts index 926b517a..e8b615cc 100644 --- a/packages/player/src/core/judge-window.test.ts +++ b/packages/player/src/core/judge-window.test.ts @@ -1,50 +1,98 @@ import { createEmptyJson } from '@be-music/json'; import { describe, expect, test } from 'vitest'; -import { resolveBmsJudgeWindowsMsForPercent, resolveJudgeWindowsMs } from './judge-window.ts'; +import { + bmsExRankValueToJudgeRankPercent, + resolveBmsJudgeWindowsMsForExRankValue, + resolveBmsJudgeWindowsMsForPercent, + resolveJudgeWindowsMs, +} from './judge-window.ts'; describe('judge-window', () => { - test('BMS: defExRank overrides metadata rank and scales all windows', () => { + test('BMS: #RANK maps onto the measured LR2 windows with a fixed ±200ms BAD gate', () => { + const expectations: Array<[number, number, number, number]> = [ + [0, 8, 24, 40], // VERY HARD + [1, 15, 30, 60], // HARD + [2, 18, 40, 100], // NORMAL + [3, 21, 60, 120], // EASY + [4, 18, 40, 100], // VERY EASY — LR2 treats #RANK 4 as NORMAL + ]; + for (const [rank, pgreat, great, good] of expectations) { + const json = createEmptyJson('bms'); + json.metadata.rank = rank; + const windows = resolveJudgeWindowsMs(json); + expect(windows.pgreat, `rank ${rank}`).toBeCloseTo(pgreat, 6); + expect(windows.great, `rank ${rank}`).toBeCloseTo(great, 6); + expect(windows.good, `rank ${rank}`).toBeCloseTo(good, 6); + expect(windows.bad, `rank ${rank}`).toBe(200); + } + }); + + test('BMS: out-of-range #RANK falls back to NORMAL', () => { + const fallbackJson = createEmptyJson('bms'); + fallbackJson.metadata.rank = 99; + const windows = resolveJudgeWindowsMs(fallbackJson); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.bad).toBe(200); + }); + + test('BMS: #DEFEXRANK interpolates between the LR2 rank anchors and overrides #RANK', () => { const json = createEmptyJson('bms'); json.metadata.rank = 0; - json.bms.defExRank = 150; + json.bms.defExRank = 120; // → percent 90, between NORMAL (75) and EASY (100) const windows = resolveJudgeWindowsMs(json); - - expect(windows.pgreat).toBeCloseTo(25.005, 3); - expect(windows.great).toBeCloseTo(49.995, 3); - expect(windows.good).toBeCloseTo(175.005, 3); - expect(windows.bad).toBeCloseTo(375, 6); + expect(windows.pgreat).toBeCloseTo(18 + 15 * ((21 - 18) / 25), 6); // 19.8 + expect(windows.great).toBeCloseTo(40 + 15 * ((60 - 40) / 25), 6); // 52 + expect(windows.good).toBeCloseTo(100 + 15 * ((120 - 100) / 25), 6); // 112 + expect(windows.bad).toBe(200); }); - test('BMS: rank table and invalid fallback both resolve correctly', () => { - const easyJson = createEmptyJson('bms'); - easyJson.metadata.rank = 0; - expect(resolveJudgeWindowsMs(easyJson).bad).toBeCloseTo(250 / 3, 6); + test('BMS: #DEFEXRANK beyond EASY extrapolates but never exceeds the BAD gate', () => { + const json = createEmptyJson('bms'); + json.bms.defExRank = 1000; // percent 750 — GOOD would extrapolate to 620ms without the clamp - const fallbackJson = createEmptyJson('bms'); - fallbackJson.metadata.rank = 99; - expect(resolveJudgeWindowsMs(fallbackJson).bad).toBeCloseTo(250, 6); + const windows = resolveJudgeWindowsMs(json); + expect(windows.good).toBe(200); // clamped to the fixed BAD width + expect(windows.pgreat).toBeCloseTo(18 + (750 - 75) * ((21 - 18) / 25), 6); // 99, below the gate + expect(windows.bad).toBe(200); }); - test('BMSON: judge rank prefers bmson info, then metadata, then default', () => { + test('BMSON: judge rank prefers bmson info, then metadata, then the NORMAL default', () => { const bmsonJson = createEmptyJson('bmson'); - bmsonJson.bmson.info.judgeRank = 140; + bmsonJson.bmson.info.judgeRank = 140; // percent 105 bmsonJson.metadata.rank = 60; - expect(resolveJudgeWindowsMs(bmsonJson).bad).toBeCloseTo(350, 6); + expect(resolveJudgeWindowsMs(bmsonJson).great).toBeCloseTo(40 + 30 * ((60 - 40) / 25), 6); // 64 - bmsonJson.bmson.info.judgeRank = 0; - expect(resolveJudgeWindowsMs(bmsonJson).bad).toBeCloseTo(150, 6); + bmsonJson.bmson.info.judgeRank = 0; // invalid → metadata rank 60 → percent 45 + expect(resolveJudgeWindowsMs(bmsonJson).pgreat).toBeCloseTo(8 + 20 * ((15 - 8) / 25), 6); // 13.6 - bmsonJson.metadata.rank = 0; - expect(resolveJudgeWindowsMs(bmsonJson).bad).toBeCloseTo(250, 6); + bmsonJson.metadata.rank = 0; // invalid → spec default 100 → percent 75 (NORMAL) + expect(resolveJudgeWindowsMs(bmsonJson).good).toBeCloseTo(100, 6); + expect(resolveJudgeWindowsMs(bmsonJson).bad).toBe(200); }); - test('resolveBmsJudgeWindowsMsForPercent honors debug bad window override only for BAD', () => { - const windows = resolveBmsJudgeWindowsMsForPercent(125, 310); + test('resolveBmsJudgeWindowsMsForPercent honors the debug bad window override only for BAD', () => { + const windows = resolveBmsJudgeWindowsMsForPercent(75, 310); - expect(windows.pgreat).toBeCloseTo(27.783333, 6); - expect(windows.great).toBeCloseTo(55.55, 6); - expect(windows.good).toBeCloseTo(194.45, 6); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.great).toBeCloseTo(40, 6); + expect(windows.good).toBeCloseTo(100, 6); expect(windows.bad).toBe(310); }); + + test('bmsExRankValueToJudgeRankPercent maps the RANK 2 = 100 unit onto the internal 75 baseline', () => { + expect(bmsExRankValueToJudgeRankPercent(100)).toBeCloseTo(75, 6); + expect(bmsExRankValueToJudgeRankPercent(120)).toBeCloseTo(90, 6); + expect(bmsExRankValueToJudgeRankPercent(0)).toBe(0); + }); + + test('resolveBmsJudgeWindowsMsForExRankValue shares the RANK 2 = 100 unit with #DEFEXRANK', () => { + const dynamic = resolveBmsJudgeWindowsMsForExRankValue(100); + expect(dynamic).toEqual(resolveBmsJudgeWindowsMsForPercent(bmsExRankValueToJudgeRankPercent(100))); + expect(dynamic.pgreat).toBeCloseTo(18, 6); + expect(dynamic.bad).toBe(200); + + // `#EXRANK 120` matches the documented `#DEFEXRANK 120` interpolation. + expect(resolveBmsJudgeWindowsMsForExRankValue(120).great).toBeCloseTo(52, 6); + }); }); diff --git a/packages/player/src/core/judge-window.ts b/packages/player/src/core/judge-window.ts index 6660892c..b7127436 100644 --- a/packages/player/src/core/judge-window.ts +++ b/packages/player/src/core/judge-window.ts @@ -1,12 +1,38 @@ import type { BeMusicJson } from '@be-music/json'; -const IIDX_PGREAT_WINDOW_MS = 16.67; -const IIDX_GREAT_WINDOW_MS = 33.33; -const IIDX_GOOD_WINDOW_MS = 116.67; -const IIDX_BAD_WINDOW_MS = 250; -const BEATORAJA_BMS_JUDGERANK_MULTIPLIERS = [25, 50, 75, 100, 125] as const; -const BEATORAJA_BMS_DEFAULT_JUDGERANK = BEATORAJA_BMS_JUDGERANK_MULTIPLIERS[2]; -const BEATORAJA_BMSON_DEFAULT_JUDGERANK = 100; +/** + * LR2 judge windows. + * + * Source of truth: LR2 measurements collected on hitkey's diary (2015-01-19 "Memo: LR2 timegates", + * https://hitkey.nekokan.dyndns.info/diary1501.php#D150119) and the machine-readable LR2-compat tables in lr2oraja + * (`JudgeProperty.java`, `LR2_SCALING`): + * + * | #RANK | PGREAT | GREAT | GOOD | BAD | + * | ------------- | ------ | ----- | ----- | ----- | + * | 0 VERY HARD | ±8ms | ±24ms | ±40ms | ±200ms| + * | 1 HARD | ±15ms | ±30ms | ±60ms | ±200ms| + * | 2 NORMAL | ±18ms | ±40ms | ±100ms| ±200ms| + * | 3 EASY | ±21ms | ±60ms | ±120ms| ±200ms| + * | 4 VERY EASY | = NORMAL (LR2 treats #RANK 4 as NORMAL — lr2oraja README) | + * + * The BAD window is FIXED at ±200ms for every rank; only PGREAT / GREAT / GOOD scale. Scratch shares the key + * windows (no widened scratch gate in LR2). + * + * Percent axis: ranks sit on a "judgerank percent" axis (VERY HARD = 25, HARD = 50, NORMAL = 75, EASY = 100) and + * fractional percents (from `#DEFEXRANK` / `#EXRANKxx` / bmson `judge_rank`) interpolate piecewise-linearly between + * the measured anchors — the same model lr2oraja uses (`JudgeWindowRule.LR2`). Values beyond EASY extrapolate along + * the last segment and every scaled window is clamped to the fixed BAD width. + */ +const LR2_BAD_WINDOW_MS = 200; +const LR2_JUDGE_RANK_ANCHOR_PERCENTS = [0, 25, 50, 75, 100] as const; +const LR2_PGREAT_ANCHORS_MS = [0, 8, 15, 18, 21] as const; +const LR2_GREAT_ANCHORS_MS = [0, 24, 30, 40, 60] as const; +const LR2_GOOD_ANCHORS_MS = [0, 40, 60, 100, 120] as const; +/** `#RANK 0..4` → judgerank percent. `#RANK 4` (VERY EASY) maps onto NORMAL, mirroring LR2. */ +const LR2_BMS_RANK_JUDGERANK_PERCENTS = [25, 50, 75, 100, 75] as const; +const LR2_NORMAL_JUDGERANK_PERCENT = LR2_BMS_RANK_JUDGERANK_PERCENTS[2]; +/** bmson `judge_rank` default per the 1.0.0 spec (100 = the player's default width, i.e. NORMAL here). */ +const BMSON_DEFAULT_JUDGERANK = 100; export interface JudgeWindowsMs { pgreat: number; @@ -15,60 +41,102 @@ export interface JudgeWindowsMs { bad: number; } +/** + * Converts a `#DEFEXRANK` / `#EXRANKxx` / bmson `judge_rank` value to the internal judgerank percent. + * + * All three share the same unit: a percentage with "100 = NORMAL" (hitkey command memo for `#DEFEXRANK`: "The value + * 100 corresponds to #RANK 2"; bmson 1.0.0: 100 = the player's default). NORMAL sits at + * `LR2_NORMAL_JUDGERANK_PERCENT` (= 75) on the internal axis, so the conversion is `value × 75 / 100`. Every + * consumer that turns such a value into judge windows must route through this function — it is the single place the + * unit is defined. + */ +export function bmsExRankValueToJudgeRankPercent(value: number): number { + return (value * LR2_NORMAL_JUDGERANK_PERCENT) / 100; +} + function resolveBmsJudgeRankPercent(json: BeMusicJson): number { const defExRank = json.bms.defExRank; if (typeof defExRank === 'number' && Number.isFinite(defExRank) && defExRank > 0) { - return (defExRank * BEATORAJA_BMS_DEFAULT_JUDGERANK) / 100; + return bmsExRankValueToJudgeRankPercent(defExRank); } const rankValue = Number.isFinite(json.metadata.rank) ? Math.trunc(json.metadata.rank!) : Number.NaN; - if (Number.isFinite(rankValue) && rankValue >= 0 && rankValue < BEATORAJA_BMS_JUDGERANK_MULTIPLIERS.length) { - return BEATORAJA_BMS_JUDGERANK_MULTIPLIERS[rankValue as 0 | 1 | 2 | 3 | 4]; + if (Number.isFinite(rankValue) && rankValue >= 0 && rankValue < LR2_BMS_RANK_JUDGERANK_PERCENTS.length) { + return LR2_BMS_RANK_JUDGERANK_PERCENTS[rankValue as 0 | 1 | 2 | 3 | 4]; } - return BEATORAJA_BMS_DEFAULT_JUDGERANK; + return LR2_NORMAL_JUDGERANK_PERCENT; } function resolveBmsonJudgeRankPercent(json: BeMusicJson): number { const judgeRank = json.bmson.info.judgeRank; if (Number.isFinite(judgeRank) && (judgeRank ?? 0) > 0) { - return judgeRank!; + return bmsExRankValueToJudgeRankPercent(judgeRank!); } const metadataRank = json.metadata.rank; if (Number.isFinite(metadataRank) && (metadataRank ?? 0) > 0) { - return metadataRank!; + return bmsExRankValueToJudgeRankPercent(metadataRank!); } - return BEATORAJA_BMSON_DEFAULT_JUDGERANK; + return bmsExRankValueToJudgeRankPercent(BMSON_DEFAULT_JUDGERANK); } export function resolveBmsJudgeWindowsMsForPercent( judgeRankPercent: number, debugBadWindowMs?: number, ): JudgeWindowsMs { - return scaleJudgeWindowsMs(judgeRankPercent, BEATORAJA_BMS_DEFAULT_JUDGERANK, debugBadWindowMs); + return scaleJudgeWindowsMs(judgeRankPercent, debugBadWindowMs); +} + +/** + * Resolves judge windows for a dynamic `#EXRANKxx` value applied via channel `A0` mid-chart. + * + * `#EXRANKxx` shares `#DEFEXRANK`'s unit (`RANK 2 = 100`, hitkey command memo), so the value goes through + * {@link bmsExRankValueToJudgeRankPercent} before scaling — `#EXRANK 100` lands on exactly the same windows as + * `#DEFEXRANK 100` (NORMAL). + */ +export function resolveBmsJudgeWindowsMsForExRankValue(exRankValue: number, debugBadWindowMs?: number): JudgeWindowsMs { + return resolveBmsJudgeWindowsMsForPercent(bmsExRankValueToJudgeRankPercent(exRankValue), debugBadWindowMs); } export function resolveJudgeWindowsMs(json: BeMusicJson, debugBadWindowMs?: number): JudgeWindowsMs { - const bmsonStyle = json.sourceFormat === 'bmson'; - const baseJudgerank = bmsonStyle ? BEATORAJA_BMSON_DEFAULT_JUDGERANK : BEATORAJA_BMS_DEFAULT_JUDGERANK; - const judgeRank = bmsonStyle ? resolveBmsonJudgeRankPercent(json) : resolveBmsJudgeRankPercent(json); - return scaleJudgeWindowsMs(judgeRank, baseJudgerank, debugBadWindowMs); + const judgeRank = json.sourceFormat === 'bmson' ? resolveBmsonJudgeRankPercent(json) : resolveBmsJudgeRankPercent(json); + return scaleJudgeWindowsMs(judgeRank, debugBadWindowMs); } -function scaleJudgeWindowsMs(judgeRank: number, baseJudgerank: number, debugBadWindowMs?: number): JudgeWindowsMs { - const scale = judgeRank / baseJudgerank; - const pgreat = IIDX_PGREAT_WINDOW_MS * scale; - const great = IIDX_GREAT_WINDOW_MS * scale; - const good = IIDX_GOOD_WINDOW_MS * scale; - const badFromRank = IIDX_BAD_WINDOW_MS * scale; +function scaleJudgeWindowsMs(judgeRankPercent: number, debugBadWindowMs?: number): JudgeWindowsMs { const bad = typeof debugBadWindowMs === 'number' && Number.isFinite(debugBadWindowMs) && debugBadWindowMs > 0 ? debugBadWindowMs - : badFromRank; + : LR2_BAD_WINDOW_MS; + const percent = Number.isFinite(judgeRankPercent) ? Math.max(0, judgeRankPercent) : LR2_NORMAL_JUDGERANK_PERCENT; return { - pgreat, - great, - good, + pgreat: clampWindow(interpolateAnchors(LR2_PGREAT_ANCHORS_MS, percent), bad), + great: clampWindow(interpolateAnchors(LR2_GREAT_ANCHORS_MS, percent), bad), + good: clampWindow(interpolateAnchors(LR2_GOOD_ANCHORS_MS, percent), bad), bad, }; } + +/** + * Piecewise-linear interpolation of an LR2 window over the judgerank percent anchors; percents beyond the EASY + * anchor (100) extrapolate along the final segment, matching lr2oraja's `JudgeWindowRule.LR2`. + */ +function interpolateAnchors(anchors: readonly number[], percent: number): number { + const points = LR2_JUDGE_RANK_ANCHOR_PERCENTS; + for (let index = 1; index < points.length; index += 1) { + if (percent <= points[index]! || index === points.length - 1) { + const x0 = points[index - 1]!; + const x1 = points[index]!; + const y0 = anchors[index - 1]!; + const y1 = anchors[index]!; + return y0 + ((percent - x0) * (y1 - y0)) / (x1 - x0); + } + } + return anchors.at(-1)!; +} + +function clampWindow(value: number, badWindowMs: number): number { + // LR2 never lets a scaled window exceed the fixed BAD gate (ralba: "#DEFEXRANK でどれだけ拡げても BAD の判定幅を + // 超えない"), and a negative width is meaningless. + return Math.min(Math.max(0, value), badWindowMs); +} diff --git a/packages/player/src/core/scroll-distance.test.ts b/packages/player/src/core/scroll-distance.test.ts index c0d10407..0c2f19b3 100644 --- a/packages/player/src/core/scroll-distance.test.ts +++ b/packages/player/src/core/scroll-distance.test.ts @@ -18,10 +18,21 @@ describe('scroll distance', () => { ], ); - expect(mapper.distanceBetween(0, 4)).toBeCloseTo(8, 6); + // Before the first #SPEED keyframe its value (3) holds flat — Bemuse semantics — while #SCROLL stays at the + // implicit 1 until its first event: 3 × 1 × 4 beats. + expect(mapper.distanceBetween(0, 4)).toBeCloseTo(12, 6); expect(mapper.distanceBetween(4, 8)).toBeCloseTo(16, 6); }); + test('#SPEED holds the first keyframe value before its beat instead of ramping from 1', () => { + const mapper = createScrollDistanceMapper(undefined, [{ beat: 4, speed: 2 }]); + + expect(mapper.distanceBetween(0, 4)).toBeCloseTo(8, 6); + // #SCROLL is genuinely 1 before its first event — the head seeding only applies to #SPEED. + const scrollOnly = createScrollDistanceMapper([{ beat: 4, speed: 2 }], undefined); + expect(scrollOnly.distanceBetween(0, 4)).toBeCloseTo(4, 6); + }); + test('lookahead helpers handle bidirectional and zero-scroll segments', () => { const mapper = createScrollDistanceMapper( [ @@ -38,9 +49,18 @@ describe('scroll distance', () => { }); test('maxBeatWithinDistance solves within interpolated speed segments', () => { - const mapper = createScrollDistanceMapper(undefined, [{ beat: 4, speed: 3 }], { lookaheadBeats: 8 }); + const mapper = createScrollDistanceMapper( + undefined, + [ + { beat: 4, speed: 1 }, + { beat: 8, speed: 3 }, + ], + { lookaheadBeats: 12 }, + ); - expect(mapper.maxBeatWithinDistance(0, 4)).toBeCloseTo(-2 + 2 * Math.sqrt(5), 6); + // Speed holds 1 until beat 4, then ramps 1 → 3 toward beat 8: distance(4..t) = (t - 4) + (t - 4)² / 4. + // distance(0, t) = 8 → solve 4 + (t-4) + (t-4)²/4 = 8 → t = 4 + (-2 + 2√5). + expect(mapper.maxBeatWithinDistance(0, 8)).toBeCloseTo(4 + (-2 + 2 * Math.sqrt(5)), 6); }); test('invalidDistance option customizes invalid input fallback', () => { diff --git a/packages/player/src/core/scroll-distance.ts b/packages/player/src/core/scroll-distance.ts index 31576be8..e0f9920b 100644 --- a/packages/player/src/core/scroll-distance.ts +++ b/packages/player/src/core/scroll-distance.ts @@ -30,6 +30,13 @@ interface SegmentDistanceTerms { interface TimelinePointNormalizerOptions { allowNegativeSpeed: boolean; dropConsecutiveSameSpeed: boolean; + /** + * Seeds the synthesized beat-0 head point with the FIRST keyframe's speed instead of `1`. `#SPEEDxx` semantics + * (Bemuse reference implementation) hold the first keyframe's value before its beat — without this, the span before + * the first keyframe would ramp linearly from 1.0. `#SCROLLxx` keeps the `1` head: scroll is piecewise-constant and + * the factor genuinely is 1 until the first `SC` event. + */ + headSpeedFromFirstPoint: boolean; } const DEFAULT_SCROLL_LOOKAHEAD_BEATS = 4 * 64; @@ -197,6 +204,7 @@ function normalizeScrollPoints(timeline?: ReadonlyArray): S return normalizeTimelinePoints(timeline, { allowNegativeSpeed: true, dropConsecutiveSameSpeed: true, + headSpeedFromFirstPoint: false, }); } @@ -204,6 +212,7 @@ function normalizeSpeedPoints(timeline?: ReadonlyArray): Spe return normalizeTimelinePoints(timeline, { allowNegativeSpeed: false, dropConsecutiveSameSpeed: false, + headSpeedFromFirstPoint: true, }); } @@ -211,7 +220,7 @@ function normalizeTimelinePoints | undefined, options: TimelinePointNormalizerOptions, ): T[] { - const points: Array<{ beat: number; speed: number }> = [{ beat: 0, speed: 1 }]; + const collected: Array<{ beat: number; speed: number }> = []; for (const point of timeline ?? []) { if ( !Number.isFinite(point.beat) || @@ -221,12 +230,14 @@ function normalizeTimelinePoints left.beat - right.beat); + collected.sort((left, right) => left.beat - right.beat); + const headSpeed = options.headSpeedFromFirstPoint && collected.length > 0 ? collected[0]!.speed : 1; + const points: Array<{ beat: number; speed: number }> = [{ beat: 0, speed: headSpeed }, ...collected]; const merged: Array<{ beat: number; speed: number }> = []; for (const point of points) { diff --git a/packages/player/src/index.test.ts b/packages/player/src/index.test.ts index 42788372..9af0d814 100644 --- a/packages/player/src/index.test.ts +++ b/packages/player/src/index.test.ts @@ -210,10 +210,20 @@ function createLandmineOnlyChart(options: { includeExplosionSound?: boolean; val if (includeExplosionSound) { json.resources.wav['00'] = 'explode.wav'; } - json.events = [{ measure: 0, channel: 'D1', position: [0, 1] as const, value }]; + // Mine on measure 1 (chart 2.0 s at BPM 120) so a held key can deterministically detonate it as it crosses the + // judge line. A mine on measure 0 (chart 0 s) is racy under LR2's passage-based detonation: with playback sped up, + // a single poll tick can advance chart time past the mine's GOOD window before the input is processed. + json.events = [{ measure: 1, channel: 'D1', position: [0, 1] as const, value }]; return json; } +// Deterministic landmine detonation — holds the 1P key-1 lane (`z` → channel 11) from 0.2 s through the mine's +// passage at chart 2.0 s, then ends the run at 2.3 s. Mirrors the proven hold-through test timing so CI doesn't race. +const HELD_LANDMINE_INPUT: Array<{ delayMs: number; command: PlayerInputCommand }> = [ + { delayMs: 200, command: { kind: 'kitty-state', pressTokens: ['z'], repeatTokens: [], releaseTokens: [] } }, + { delayMs: 2300, command: { kind: 'interrupt', reason: 'escape' } }, +]; + function createScheduledInputRuntime(commands: Array<{ delayMs: number; command: PlayerInputCommand }>) { return ({ inputSignals }: { inputSignals: { pushCommand: (command: PlayerInputCommand) => void } }) => { const timers: ReturnType[] = []; @@ -897,23 +907,22 @@ describe('player', () => { expect(releaseIndex).toBeGreaterThan(holdIndex); }); - test('player: stray key fires LR2 empty POOR — gauge -2 but combo / score / poor counter untouched', async () => { + test('player: stray key in front of a note fires LR2 empty POOR — gauge -2, counters untouched', async () => { + // BPM 240 → measure 1 starts at chart 1.0 s. The press lands at ~0.3 s, i.e. ~0.7 s before the note — outside + // the BAD window but inside LR2's 1-second early 空POOR region. const json = createEmptyJson('bms'); - json.metadata.bpm = 60; + json.metadata.bpm = 240; json.events = [{ measure: 1, channel: '11', position: [0, 1], value: '01' }]; const summary = await manualPlay(json, { - speed: 64, + speed: 1, leadInMs: 0, audio: false, tui: false, - createInputRuntime: ({ inputSignals }) => ({ - start: () => { - inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); - inputSignals.pushCommand({ kind: 'interrupt', reason: 'escape' }); - }, - stop: () => undefined, - }), + createInputRuntime: createScheduledInputRuntime([ + { delayMs: 300, command: { kind: 'lane-input', tokens: ['z'] } }, + { delayMs: 400, command: { kind: 'interrupt', reason: 'escape' } }, + ]), }); expect(summary.total).toBe(1); @@ -930,6 +939,36 @@ describe('player', () => { expect(summary.gauge?.cleared).toBe(false); }); + test('player: stray key with no note within 1 s is harmless (LR2 — no empty POOR)', async () => { + // BPM 60 → the only note sits at chart 4.0 s. A press at ~0 s is far outside LR2's 1-second early 空POOR + // region, so nothing charges: no gauge drain, no POOR. + const json = createEmptyJson('bms'); + json.metadata.bpm = 60; + json.events = [{ measure: 1, channel: '11', position: [0, 1], value: '01' }]; + + const output: string[] = []; + const summary = await manualPlay(json, { + speed: 64, + leadInMs: 0, + audio: false, + tui: false, + writeOutput: (text) => { + output.push(text); + }, + createInputRuntime: ({ inputSignals }) => ({ + start: () => { + inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); + inputSignals.pushCommand({ kind: 'interrupt', reason: 'escape' }); + }, + stop: () => undefined, + }), + }); + + expect(summary.poor).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(20, 9); + expect(output.some((line) => line.includes('result:EMPTY_POOR'))).toBe(false); + }); + test('player: blank press between same-lane notes plays the previous keysound, not the next pending keysound', async () => { const json = createEmptyJson('bms'); json.metadata.bpm = 240; @@ -1134,24 +1173,20 @@ describe('player', () => { const output: string[] = []; const summary = await manualPlay(json, { - speed: 240, + speed: 1, leadInMs: 0, audio: false, tui: false, writeOutput: (text) => { output.push(text); }, - createInputRuntime: ({ inputSignals }) => ({ - start: () => { - inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); - inputSignals.pushCommand({ kind: 'interrupt', reason: 'escape' }); - }, - stop: () => undefined, - }), + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), }); expect(summary.total).toBe(0); - expect(summary.bad).toBe(1); + // LR2 — mine hits never emit a verdict: gauge damage + explosion sound only. + expect(summary.bad).toBe(0); + expect(summary.poor).toBe(0); expect( output.some( (line) => @@ -1165,46 +1200,135 @@ describe('player', () => { expect(output.some((line) => line.includes('kind:mine-hit') && line.includes('channel:11'))).toBe(true); }); - test('player: manual landmine hit applies value-based damage while keeping BAD judgment', async () => { + test('player: manual landmine hit applies the LR2 raw-value damage with no verdict', async () => { const summary = await manualPlay(createLandmineOnlyChart({ value: '08' }), { - speed: 240, + speed: 1, leadInMs: 0, audio: false, tui: false, - createInputRuntime: ({ inputSignals }) => ({ - start: () => { - inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); - inputSignals.pushCommand({ kind: 'interrupt', reason: 'escape' }); - }, - stop: () => undefined, - }), + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), }); expect(summary.total).toBe(0); - expect(summary.bad).toBe(1); + expect(summary.bad).toBe(0); expect(summary.poor).toBe(0); - expect(summary.gauge?.current).toBeCloseTo(16, 9); + // LR2 / beatoraja interpret the mine value directly as the damage percent ('08' = 8 %, NOT the nanasi-memo + // value/2): gauge 20 → 12. + expect(summary.gauge?.current).toBeCloseTo(12, 9); }); test('player: manual landmine hit clamps large mine damage at the groove gauge minimum', async () => { const summary = await manualPlay(createLandmineOnlyChart({ value: 'ZZ' }), { - speed: 240, + speed: 1, leadInMs: 0, audio: false, tui: false, - createInputRuntime: ({ inputSignals }) => ({ - start: () => { - inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); - inputSignals.pushCommand({ kind: 'interrupt', reason: 'escape' }); - }, - stop: () => undefined, - }), + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), }); - expect(summary.bad).toBe(1); + expect(summary.bad).toBe(0); + // ZZ (= 1295 % raw) wipes the gauge; GROOVE's 2 % soft floor catches it (a survival gauge would die instead). expect(summary.gauge?.current).toBeCloseTo(2, 9); }); + test('player: bmson per-mine damage overrides the BMS value/2 rule', async () => { + // bmson `key_channels[].notes[].damage` is an explicit gauge percentage carried on `event.bmson.damage`. When + // present it wins over the BMS raw-value interpretation: value '08' alone would deal 8 %, the authored damage + // of 7 must deal exactly 7 % (gauge 20 → 13). + const json = createLandmineOnlyChart({ value: '08' }); + json.events[0]!.bmson = { damage: 7 }; + + const summary = await manualPlay(json, { + speed: 1, + leadInMs: 0, + audio: false, + tui: false, + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), + }); + + expect(summary.bad).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(13, 9); + }); + + test('player: bmson damage 0 is a valid no-damage decoration mine', async () => { + const json = createLandmineOnlyChart({ value: '08' }); + json.events[0]!.bmson = { damage: 0 }; + + const summary = await manualPlay(json, { + speed: 1, + leadInMs: 0, + audio: false, + tui: false, + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), + }); + + expect(summary.bad).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(20, 9); + }); + + test('player: holding a key through a passing mine detonates it (LR2 hold-through)', async () => { + // BPM 120 → measure 1 = 2.0 s. Hold the lane from ~0.2 s via kitty press state (no release) and let the mine + // cross the judge line at 2.0 s — LR2 explodes mines that pass while the key is ON. + const json = createEmptyJson('bms'); + json.metadata.bpm = 120; + json.events = [{ measure: 1, channel: 'D1', position: [0, 1] as const, value: '0A' }]; // raw 10 % + + const summary = await manualPlay(json, { + speed: 1, + leadInMs: 0, + audio: false, + tui: false, + createInputRuntime: createScheduledInputRuntime([ + { delayMs: 200, command: { kind: 'kitty-state', pressTokens: ['z'], repeatTokens: [], releaseTokens: [] } }, + { delayMs: 2300, command: { kind: 'interrupt', reason: 'escape' } }, + ]), + }); + + expect(summary.bad).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(10, 9); // 20 - 10 + }); + + test('player: a mine passing with the key up is harmless (LR2)', async () => { + const json = createEmptyJson('bms'); + json.metadata.bpm = 120; + json.events = [{ measure: 1, channel: 'D1', position: [0, 1] as const, value: '0A' }]; + + const summary = await manualPlay(json, { + speed: 1, + leadInMs: 0, + audio: false, + tui: false, + createInputRuntime: createScheduledInputRuntime([ + { delayMs: 2300, command: { kind: 'interrupt', reason: 'escape' } }, + ]), + }); + + expect(summary.bad).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(20, 9); + }); + + test('player: a press outside the GOOD window does not detonate an approaching mine (LR2)', async () => { + // BPM 120, NORMAL rank → GOOD window ±100 ms. The mine sits at 2.0 s; a tap at ~1.7 s is 300 ms early — + // outside the detonation range — and the key is up again (grace expired) by the time the mine crosses. + const json = createEmptyJson('bms'); + json.metadata.bpm = 120; + json.events = [{ measure: 1, channel: 'D1', position: [0, 1] as const, value: '0A' }]; + + const summary = await manualPlay(json, { + speed: 1, + leadInMs: 0, + audio: false, + tui: false, + createInputRuntime: createScheduledInputRuntime([ + { delayMs: 1700, command: { kind: 'lane-input', tokens: ['z'] } }, + { delayMs: 2300, command: { kind: 'interrupt', reason: 'escape' } }, + ]), + }); + + expect(summary.bad).toBe(0); + expect(summary.gauge?.current).toBeCloseTo(20, 9); + }); + test('player: routes audio through createAudioSession factory when supplied', async () => { // Phase 1 of the web-engine integration plan exposes a `createAudioSession` factory option so the browser // runtime can plug in a Web Audio backend without forking the engine. The factory's returned `AudioSession` @@ -1292,8 +1416,10 @@ describe('player', () => { }); test('player: manual landmine hit sounds #WAV00 when audio is enabled', async () => { + // `explode.wav` doesn't exist on disk, so the audible assertion opts into the debug fallback tone — the + // spec-compliant default for a missing sample is silence (covered by the companion test below). await manualPlay(createLandmineOnlyChart(), { - speed: 240, + speed: 1, leadInMs: 0, audio: true, audioHeadPaddingMs: 0, @@ -1301,18 +1427,31 @@ describe('player', () => { audioLeadMaxMs: 0, limiter: false, tui: false, + missingSampleToneSeconds: 0.06, writeOutput: () => undefined, - createInputRuntime: ({ inputSignals }) => ({ - start: () => { - inputSignals.pushCommand({ kind: 'lane-input', tokens: ['z'] }); - }, - stop: () => undefined, - }), + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), }); expect(hasAnyNonSilentAudioWrite()).toBe(true); }); + test('player: missing #WAVxx files are silent by default (no fallback tone)', async () => { + await manualPlay(createLandmineOnlyChart(), { + speed: 1, + leadInMs: 0, + audio: true, + audioHeadPaddingMs: 0, + audioLeadMs: 0, + audioLeadMaxMs: 0, + limiter: false, + tui: false, + writeOutput: () => undefined, + createInputRuntime: createScheduledInputRuntime(HELD_LANDMINE_INPUT), + }); + + expect(hasAnyNonSilentAudioWrite()).toBe(false); + }); + test('player: derives long-note end beat from bmson notes.l', () => { const json = createEmptyJson('bmson'); json.metadata.bpm = 120; @@ -1323,6 +1462,25 @@ describe('player', () => { expect(notes).toHaveLength(1); expect(notes[0].beat).toBe(0); expect(notes[0].endBeat).toBeCloseTo(1, 6); + // beatoraja bmson extension: unspecified `info.ln_type` / note `t` falls back to the LR2-aligned default LN + // (mode 1, no tail release judgment) — the same default the BMS side uses for a missing #LNMODE. + expect(notes[0].longNoteMode).toBe(1); + }); + + test('player: bmson note t overrides info.ln_type, which overrides the LN default', () => { + const json = createEmptyJson('bmson'); + json.metadata.bpm = 120; + json.bmson.info.resolution = 240; + json.bmson.info.lnType = 2; + json.events = [ + { measure: 0, channel: '11', position: [0, 1], value: '01', bmson: { l: 240, t: 3 } }, + { measure: 1, channel: '12', position: [0, 1], value: '02', bmson: { l: 240 } }, + ]; + + const notes = extractPlayableNotes(json); + expect(notes).toHaveLength(2); + expect(notes[0].longNoteMode).toBe(3); // per-note t wins + expect(notes[1].longNoteMode).toBe(2); // chart-level info.ln_type }); test('player: derives long-note end beat from bms #LNOBJ', () => { @@ -1787,10 +1945,10 @@ describe('player', () => { const json = createEmptyJson('bms'); json.metadata.rank = 2; const windows = resolveJudgeWindowsMs(json); - expect(windows.pgreat).toBeCloseTo(16.67, 6); - expect(windows.great).toBeCloseTo(33.33, 6); - expect(windows.good).toBeCloseTo(116.67, 6); - expect(windows.bad).toBeCloseTo(250, 6); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.great).toBeCloseTo(40, 6); + expect(windows.good).toBeCloseTo(100, 6); + expect(windows.bad).toBe(200); }); test('player: FAST/SLOW are counted only for GREAT/GOOD', () => { @@ -1849,35 +2007,35 @@ describe('player', () => { expect(resolveChartVolWavGain(defaultChart)).toBe(2); }); - test('player: narrows judge windows for bms RANK=0', () => { + test('player: narrows judge windows for bms RANK=0 (LR2 VERY HARD)', () => { const json = createEmptyJson('bms'); json.metadata.rank = 0; const windows = resolveJudgeWindowsMs(json); - expect(windows.pgreat).toBeCloseTo((16.67 * 25) / 75, 6); - expect(windows.great).toBeCloseTo((33.33 * 25) / 75, 6); - expect(windows.good).toBeCloseTo((116.67 * 25) / 75, 6); - expect(windows.bad).toBeCloseTo((250 * 25) / 75, 6); + expect(windows.pgreat).toBeCloseTo(8, 6); + expect(windows.great).toBeCloseTo(24, 6); + expect(windows.good).toBeCloseTo(40, 6); + expect(windows.bad).toBe(200); }); - test('player: widens judge windows for bms RANK=4', () => { + test('player: bms RANK=4 maps onto NORMAL windows (LR2 behavior)', () => { const json = createEmptyJson('bms'); json.metadata.rank = 4; const windows = resolveJudgeWindowsMs(json); - expect(windows.pgreat).toBeCloseTo((16.67 * 125) / 75, 6); - expect(windows.great).toBeCloseTo((33.33 * 125) / 75, 6); - expect(windows.good).toBeCloseTo((116.67 * 125) / 75, 6); - expect(windows.bad).toBeCloseTo((250 * 125) / 75, 6); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.great).toBeCloseTo(40, 6); + expect(windows.good).toBeCloseTo(100, 6); + expect(windows.bad).toBe(200); }); - test('player: scales judge windows from bms DEFEXRANK using NORMAL baseline', () => { + test('player: scales judge windows from bms DEFEXRANK via the LR2 anchor interpolation', () => { const json = createEmptyJson('bms'); json.metadata.rank = 0; - json.bms.defExRank = 120; + json.bms.defExRank = 120; // percent 90 — between NORMAL (75) and EASY (100) const windows = resolveJudgeWindowsMs(json); - expect(windows.pgreat).toBeCloseTo(16.67 * 1.2, 6); - expect(windows.great).toBeCloseTo(33.33 * 1.2, 6); - expect(windows.good).toBeCloseTo(116.67 * 1.2, 6); - expect(windows.bad).toBeCloseTo(250 * 1.2, 6); + expect(windows.pgreat).toBeCloseTo(19.8, 6); + expect(windows.great).toBeCloseTo(52, 6); + expect(windows.good).toBeCloseTo(112, 6); + expect(windows.bad).toBe(200); }); test('player: resolves displayed judge rank from bms DEFEXRANK and defaults', () => { @@ -1932,23 +2090,23 @@ describe('player', () => { expect(summary.great).toBe(0); }); - test('player: uses baseline judge windows for bmson judge_rank=100', () => { + test('player: uses NORMAL windows for bmson judge_rank=100', () => { const json = createEmptyJson('bmson'); json.bmson.info.judgeRank = 100; const windows = resolveJudgeWindowsMs(json); - expect(windows.pgreat).toBeCloseTo(16.67, 6); - expect(windows.great).toBeCloseTo(33.33, 6); - expect(windows.good).toBeCloseTo(116.67, 6); - expect(windows.bad).toBeCloseTo(250, 6); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.great).toBeCloseTo(40, 6); + expect(windows.good).toBeCloseTo(100, 6); + expect(windows.bad).toBe(200); }); test('player: debug judge window override affects BAD only', () => { const json = createEmptyJson('bms'); json.metadata.rank = 4; const windows = resolveJudgeWindowsMs(json, 180); - expect(windows.pgreat).toBeCloseTo((16.67 * 125) / 75, 6); - expect(windows.great).toBeCloseTo((33.33 * 125) / 75, 6); - expect(windows.good).toBeCloseTo((116.67 * 125) / 75, 6); + expect(windows.pgreat).toBeCloseTo(18, 6); + expect(windows.great).toBeCloseTo(40, 6); + expect(windows.good).toBeCloseTo(100, 6); expect(windows.bad).toBe(180); }); diff --git a/packages/player/src/playable-notes.ts b/packages/player/src/playable-notes.ts index 0850e56a..7b5b41ff 100644 --- a/packages/player/src/playable-notes.ts +++ b/packages/player/src/playable-notes.ts @@ -3,7 +3,9 @@ import { type BeMusicEvent, type BeMusicJson, normalizeChannel } from '@be-music import { createTimingResolver } from '@be-music/audio-renderer/triggers'; const FREE_ZONE_BEAT_LENGTH = 1; const DEFAULT_BMS_LONG_NOTE_MODE = 1; -const DEFAULT_OTHER_LONG_NOTE_MODE = 2; +// beatoraja's bmson extension leaves an unspecified `info.ln_type` to the player's default LN mode. This project's +// LR2-aligned default is LN (no tail release judgment) — the same default the BMS side uses for a missing #LNMODE. +const DEFAULT_BMSON_LONG_NOTE_MODE = 1; export type LongNoteMode = 1 | 2 | 3; @@ -50,6 +52,8 @@ interface TimedExtractionContext { beatResolver: ReturnType; sortedEvents: BeMusicEvent[]; bmsonResolution?: number; + /** Chart-level bmson `info.ln_type` (1: LN, 2: CN, 3: HCN), already validated; per-note `t` overrides it. */ + chartLongNoteType?: LongNoteMode; } interface TimedEventChannels { @@ -104,6 +108,10 @@ function createTimedExtractionContext(json: BeMusicJson): TimedExtractionContext beatResolver: createBeatResolver(json), sortedEvents: sortEvents(json.events), bmsonResolution: json.sourceFormat === 'bmson' ? Math.max(1, json.bmson.info.resolution || 240) : undefined, + chartLongNoteType: + json.bmson.info.lnType === 1 || json.bmson.info.lnType === 2 || json.bmson.info.lnType === 3 + ? json.bmson.info.lnType + : undefined, }; } @@ -145,7 +153,7 @@ function collectTimedNotes( beat, endBeat, endSeconds: endBeat !== undefined ? context.resolver.beatToSeconds(endBeat) : undefined, - longNoteMode: endBeat !== undefined ? DEFAULT_OTHER_LONG_NOTE_MODE : undefined, + longNoteMode: endBeat !== undefined ? resolveBmsonLongNoteMode(event, context.chartLongNoteType) : undefined, seconds, judged: false, }); @@ -315,7 +323,7 @@ function appendLegacyLongNotesIfNeeded( function resolveBmsLongNoteMode(json: BeMusicJson): LongNoteMode { if (json.sourceFormat !== 'bms') { - return DEFAULT_OTHER_LONG_NOTE_MODE; + return DEFAULT_BMSON_LONG_NOTE_MODE; } if (json.bms.lnMode === 2 || json.bms.lnMode === 3) { return json.bms.lnMode; @@ -323,6 +331,18 @@ function resolveBmsLongNoteMode(json: BeMusicJson): LongNoteMode { return DEFAULT_BMS_LONG_NOTE_MODE; } +/** + * Long-note mode for a bmson-derived note, per the beatoraja bmson extension precedence: per-note `t` > + * chart-level `info.ln_type` > default (LN). + */ +function resolveBmsonLongNoteMode(event: BeMusicEvent, chartLongNoteType: LongNoteMode | undefined): LongNoteMode { + const noteType = event.bmson?.t; + if (noteType === 1 || noteType === 2 || noteType === 3) { + return noteType; + } + return chartLongNoteType ?? DEFAULT_BMSON_LONG_NOTE_MODE; +} + function resolveLongNoteEndBeat( event: BeMusicEvent, beat: number, diff --git a/packages/stringifier/CHANGELOG.md b/packages/stringifier/CHANGELOG.md index a7d9a332..989bf04d 100644 --- a/packages/stringifier/CHANGELOG.md +++ b/packages/stringifier/CHANGELOG.md @@ -1,5 +1,20 @@ # @be-music/stringifier +## 0.3.1 + +### Patch Changes + +- b9922cf: Support the beatoraja bmson long-note type extensions: `info.ln_type` and per-note `t` (1: LN, 2: CN, 3: HCN) are preserved in the IR, round-trip through JSON and bmson output, and drive the player's long-note mode (per-note `t` wins over `info.ln_type`). Charts that specify neither now default to LN (no tail release judgment, matching the LR2-aligned BMS default) instead of always being treated as CN. +- Updated dependencies [b2c4f9b] +- Updated dependencies [b9922cf] +- Updated dependencies [4d5a89e] +- Updated dependencies [cdc42a1] +- Updated dependencies [ca1012c] + - @be-music/parser@0.2.3 + - @be-music/json@0.2.2 + - @be-music/utils@0.3.0 + - @be-music/chart@0.3.2 + ## 0.3.0 ### Minor Changes diff --git a/packages/stringifier/package.json b/packages/stringifier/package.json index ad966f04..01645d4a 100644 --- a/packages/stringifier/package.json +++ b/packages/stringifier/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/stringifier", - "version": "0.3.0", + "version": "0.3.1", "description": "Stringifier from be-music JSON charts to BMS or BMSON", "license": "MIT", "bin": { diff --git a/packages/stringifier/src/index.ts b/packages/stringifier/src/index.ts index a76d42d7..9c18ae73 100644 --- a/packages/stringifier/src/index.ts +++ b/packages/stringifier/src/index.ts @@ -105,7 +105,7 @@ export function stringifyBmson(json: BeMusicJson, options: BmsonStringifyOptions const soundChannels = new Map< string, - { name: string; notes: Array<{ x: number; y: number; l: number; c: boolean }> } + { name: string; notes: Array<{ x: number; y: number; l: number; c: boolean; t?: number }> } >(); const bpmEvents: Array<{ y: number; bpm: number }> = []; const stopEvents: Array<{ y: number; duration: number }> = []; @@ -130,9 +130,14 @@ export function stringifyBmson(json: BeMusicJson, options: BmsonStringifyOptions ? Math.floor(event.bmson.l) : 0; const continuation = event.bmson?.c === true; + // beatoraja bmson extension — per-note long-note type rides along only on actual long notes. + const longNoteType = + length > 0 && (event.bmson?.t === 1 || event.bmson?.t === 2 || event.bmson?.t === 3) + ? event.bmson.t + : undefined; const slot = soundChannels.get(key) ?? { name: fileName, notes: [] }; - slot.notes.push({ x, y, l: length, c: continuation }); + slot.notes.push({ x, y, l: length, c: continuation, ...(longNoteType !== undefined ? { t: longNoteType } : {}) }); soundChannels.set(key, slot); } @@ -303,6 +308,9 @@ function createPreservedBmsonInfoForOutput(json: BeMusicJson, resolution: number if (typeof info.judgeRank === 'number') { output.judge_rank = info.judgeRank; } + if (info.lnType === 1 || info.lnType === 2 || info.lnType === 3) { + output.ln_type = info.lnType; + } if (typeof info.total === 'number') { output.total = info.total; } diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 28b2231c..df74c65b 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @be-music/utils +## 0.3.0 + +### Minor Changes + +- ca1012c: Add the `@be-music/utils/optional-node-module` subpath with `loadOptionalNodeModule`, a loader for optional dependencies (native addons, large wasm packages) that falls back to `createRequire` resolution anchored next to the executable, the directory named by the `BE_MUSIC_OPTIONAL_NODE_MODULE_DIR` environment variable, and the working directory. Inside a Node single executable application (SEA), `import()` of a bare specifier always fails, so this lets SEA binaries load such modules from a `node_modules` directory shipped alongside them or extracted from embedded assets. + ## 0.2.1 ### Patch Changes diff --git a/packages/utils/package.json b/packages/utils/package.json index b2972c83..184700ee 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@be-music/utils", - "version": "0.2.1", + "version": "0.3.0", "description": "Shared utilities for be-music packages", "license": "MIT", "files": [ @@ -30,6 +30,11 @@ "source": "./src/log.ts", "default": "./dist/log.js" }, + "./optional-node-module": { + "types": "./dist/optional-node-module.d.ts", + "source": "./src/optional-node-module.ts", + "default": "./dist/optional-node-module.js" + }, "./path": { "types": "./dist/path.d.ts", "source": "./src/path.ts", diff --git a/packages/utils/src/optional-node-module.test.ts b/packages/utils/src/optional-node-module.test.ts new file mode 100644 index 00000000..380ca2e9 --- /dev/null +++ b/packages/utils/src/optional-node-module.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadOptionalNodeModule, OPTIONAL_NODE_MODULE_DIR_ENV } from './optional-node-module.ts'; + +describe('loadOptionalNodeModule', () => { + it('returns the module when the import succeeds', async () => { + const loaded = await loadOptionalNodeModule('anything', async () => ({ marker: 'imported' })); + expect(loaded).toEqual({ marker: 'imported' }); + }); + + it('falls back to createRequire resolution when the import fails', async () => { + const loaded = await loadOptionalNodeModule('node:path', () => + Promise.reject(new Error('ERR_UNKNOWN_BUILTIN_MODULE')), + ); + expect(loaded?.join).toBe(join); + }); + + it('returns undefined when the module cannot be resolved anywhere', async () => { + const loaded = await loadOptionalNodeModule('@be-music/this-module-does-not-exist', () => + Promise.reject(new Error('ERR_UNKNOWN_BUILTIN_MODULE')), + ); + expect(loaded).toBeUndefined(); + }); + + describe('with the optional-module directory environment variable', () => { + let baseDir: string | undefined; + + afterEach(async () => { + delete process.env[OPTIONAL_NODE_MODULE_DIR_ENV]; + if (baseDir) { + await rm(baseDir, { recursive: true, force: true }); + baseDir = undefined; + } + }); + + it('resolves modules from the configured node_modules directory', async () => { + baseDir = await mkdtemp(join(tmpdir(), 'be-music-optional-module-')); + const packageDir = join(baseDir, 'node_modules', 'fake-optional-package'); + await mkdir(packageDir, { recursive: true }); + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'fake-optional-package', version: '1.0.0', main: 'index.cjs' }), + ); + await writeFile(join(packageDir, 'index.cjs'), 'module.exports = { marker: "from-env-dir" };'); + process.env[OPTIONAL_NODE_MODULE_DIR_ENV] = baseDir; + + const loaded = await loadOptionalNodeModule<{ marker: string }>('fake-optional-package', () => + Promise.reject(new Error('ERR_UNKNOWN_BUILTIN_MODULE')), + ); + expect(loaded?.marker).toBe('from-env-dir'); + }); + }); +}); diff --git a/packages/utils/src/optional-node-module.ts b/packages/utils/src/optional-node-module.ts new file mode 100644 index 00000000..69c7599a --- /dev/null +++ b/packages/utils/src/optional-node-module.ts @@ -0,0 +1,51 @@ +// Lazy-load Node built-ins so the module remains importable from a browser bundle (same pattern as +// `./log.ts`); the fallback only runs after the caller's own dynamic import already failed. + +/** + * Environment variable naming an extra base directory whose `node_modules` is consulted by + * {@link loadOptionalNodeModule}. SEA binaries point it at the directory where their embedded modules were + * extracted (see `player-tui/src/node/sea-embedded-modules.ts`); it propagates to worker threads through + * the inherited environment. + */ +export const OPTIONAL_NODE_MODULE_DIR_ENV = 'BE_MUSIC_OPTIONAL_NODE_MODULE_DIR'; + +/** + * Load an optional dependency that may be missing at runtime (native addons, large wasm packages). + * + * In a Node single executable application (SEA), `import()` of a bare specifier always fails with + * `ERR_UNKNOWN_BUILTIN_MODULE` — the SEA module system never consults the filesystem. When the caller's + * import fails, retry through `createRequire` anchored next to the executable, then the directory named by + * {@link OPTIONAL_NODE_MODULE_DIR_ENV}, then the working directory, so a SEA binary can load these modules + * from a `node_modules` directory shipped alongside it or extracted from its embedded assets. + * + * Returns `undefined` when the module cannot be loaded from any location. + */ +export async function loadOptionalNodeModule( + specifier: string, + importModule: () => Promise, +): Promise { + try { + return await importModule(); + } catch { + return await requireFromRuntimeDirectories(specifier); + } +} + +async function requireFromRuntimeDirectories(specifier: string): Promise { + try { + const [{ createRequire }, { dirname, join }] = await Promise.all([import('node:module'), import('node:path')]); + const envBaseDir = process.env[OPTIONAL_NODE_MODULE_DIR_ENV]; + const baseDirs = [dirname(process.execPath), ...(envBaseDir ? [envBaseDir] : []), process.cwd()]; + for (const baseDir of baseDirs) { + try { + const requireFromBase = createRequire(join(baseDir, '__optional-node-module-resolver__.cjs')); + return requireFromBase(specifier) as T; + } catch { + // Keep probing the remaining base directories. + } + } + } catch { + // node:module / node:path are unavailable (non-Node runtime); report the module as missing. + } + return undefined; +} diff --git a/packages/utils/tsdown.config.ts b/packages/utils/tsdown.config.ts index 03eab677..699aaf11 100644 --- a/packages/utils/tsdown.config.ts +++ b/packages/utils/tsdown.config.ts @@ -13,6 +13,7 @@ export default createPackageTsdownConfig({ core: 'src/core.ts', 'cli-path': 'src/cli-path.ts', log: 'src/log.ts', + 'optional-node-module': 'src/optional-node-module.ts', path: 'src/path.ts', pcm: 'src/pcm.ts', workerize: 'src/workerize.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 640d8fd3..4e4f97cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,17 +15,17 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260607.1 - version: 7.0.0-dev.20260607.1 + specifier: 7.0.0-dev.20260611.2 + version: 7.0.0-dev.20260611.2 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@25.9.1)(lightningcss@1.32.0)(tsx@4.22.4)) oxfmt: - specifier: ^0.52.0 - version: 0.52.0 + specifier: ^0.54.0 + version: 0.54.0 oxlint: - specifier: ^1.67.0 - version: 1.67.0 + specifier: ^1.69.0 + version: 1.69.0 rimraf: specifier: ^6.0.1 version: 6.1.3 @@ -34,7 +34,7 @@ importers: version: 6.0.2 tsdown: specifier: ^0.22.2 - version: 0.22.2(@typescript/native-preview@7.0.0-dev.20260607.1)(tsx@4.22.4)(unrun@0.2.37) + version: 0.22.2(@typescript/native-preview@7.0.0-dev.20260611.2)(tsx@4.22.4)(unrun@0.2.37) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -134,9 +134,6 @@ importers: '@be-music/utils': specifier: workspace:* version: link:../utils - iconv-lite: - specifier: ^0.7.0 - version: 0.7.2 packages/player: dependencies: @@ -159,8 +156,8 @@ importers: specifier: ^3.2.1 version: 3.2.1 node-web-audio-api: - specifier: ^1.0.9 - version: 1.0.9 + specifier: ^2.0.0 + version: 2.0.0 packages/player-tui: dependencies: @@ -268,8 +265,8 @@ importers: version: 0.21.0 devDependencies: '@cloudflare/workers-types': - specifier: ^4.20260608.1 - version: 4.20260608.1 + specifier: ^4.20260611.1 + version: 4.20260611.1 ts-ebml: specifier: ^3.0.2 version: 3.0.2 @@ -280,8 +277,8 @@ importers: specifier: ^0.28.0 version: 0.28.0(rollup@4.59.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(tsx@4.22.4)) wrangler: - specifier: ^4.98.0 - version: 4.98.0(@cloudflare/workers-types@4.20260608.1) + specifier: ^4.100.0 + version: 4.100.0(@cloudflare/workers-types@4.20260611.1) packages/stringifier: dependencies: @@ -423,38 +420,38 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260603.1': - resolution: {integrity: sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==} + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260603.1': - resolution: {integrity: sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==} + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260603.1': - resolution: {integrity: sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==} + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260603.1': - resolution: {integrity: sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==} + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260603.1': - resolution: {integrity: sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==} + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260608.1': - resolution: {integrity: sha512-Yz90KXPZBB9K/AhsnLaA321s5+i5ZpWZRFbcGx9dWjyknQJXPAS1pK5CJSFEedwY6zoEr1cf2SkpBATL1idsWA==} + '@cloudflare/workers-types@4.20260611.1': + resolution: {integrity: sha512-DLiz8Ol1OIWLigJ+dGvuQ5Nm66D/CHNPasl8YnPiz6fGo10ggYSIVuEDMlFk6oho+piAHstNmZMl088w8xqW6g==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1153,6 +1150,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1174,246 +1177,246 @@ packages: '@oxc-project/types@0.134.0': resolution: {integrity: sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ==} - '@oxfmt/binding-android-arm-eabi@0.52.0': - resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} + '@oxfmt/binding-android-arm-eabi@0.54.0': + resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.52.0': - resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} + '@oxfmt/binding-android-arm64@0.54.0': + resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.52.0': - resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} + '@oxfmt/binding-darwin-arm64@0.54.0': + resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.52.0': - resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} + '@oxfmt/binding-darwin-x64@0.54.0': + resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.52.0': - resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} + '@oxfmt/binding-freebsd-x64@0.54.0': + resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': - resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.54.0': + resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': - resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.54.0': + resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.52.0': - resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} + '@oxfmt/binding-linux-arm64-gnu@0.54.0': + resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.52.0': - resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} + '@oxfmt/binding-linux-arm64-musl@0.54.0': + resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': - resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} + '@oxfmt/binding-linux-ppc64-gnu@0.54.0': + resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': - resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} + '@oxfmt/binding-linux-riscv64-gnu@0.54.0': + resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.52.0': - resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} + '@oxfmt/binding-linux-riscv64-musl@0.54.0': + resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.52.0': - resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} + '@oxfmt/binding-linux-s390x-gnu@0.54.0': + resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.52.0': - resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} + '@oxfmt/binding-linux-x64-gnu@0.54.0': + resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.52.0': - resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} + '@oxfmt/binding-linux-x64-musl@0.54.0': + resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.52.0': - resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} + '@oxfmt/binding-openharmony-arm64@0.54.0': + resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.52.0': - resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} + '@oxfmt/binding-win32-arm64-msvc@0.54.0': + resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.52.0': - resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} + '@oxfmt/binding-win32-ia32-msvc@0.54.0': + resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.52.0': - resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} + '@oxfmt/binding-win32-x64-msvc@0.54.0': + resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.67.0': - resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} + '@oxlint/binding-android-arm-eabi@1.69.0': + resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.67.0': - resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} + '@oxlint/binding-android-arm64@1.69.0': + resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.67.0': - resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} + '@oxlint/binding-darwin-arm64@1.69.0': + resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.67.0': - resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} + '@oxlint/binding-darwin-x64@1.69.0': + resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.67.0': - resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} + '@oxlint/binding-freebsd-x64@1.69.0': + resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': - resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} + '@oxlint/binding-linux-arm-gnueabihf@1.69.0': + resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.67.0': - resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} + '@oxlint/binding-linux-arm-musleabihf@1.69.0': + resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.67.0': - resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} + '@oxlint/binding-linux-arm64-gnu@1.69.0': + resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.67.0': - resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} + '@oxlint/binding-linux-arm64-musl@1.69.0': + resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.67.0': - resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} + '@oxlint/binding-linux-ppc64-gnu@1.69.0': + resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.67.0': - resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} + '@oxlint/binding-linux-riscv64-gnu@1.69.0': + resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.67.0': - resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} + '@oxlint/binding-linux-riscv64-musl@1.69.0': + resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.67.0': - resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} + '@oxlint/binding-linux-s390x-gnu@1.69.0': + resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.67.0': - resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} + '@oxlint/binding-linux-x64-gnu@1.69.0': + resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.67.0': - resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} + '@oxlint/binding-linux-x64-musl@1.69.0': + resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.67.0': - resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} + '@oxlint/binding-openharmony-arm64@1.69.0': + resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.67.0': - resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} + '@oxlint/binding-win32-arm64-msvc@1.69.0': + resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.67.0': - resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} + '@oxlint/binding-win32-ia32-msvc@1.69.0': + resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.67.0': - resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} + '@oxlint/binding-win32-x64-msvc@1.69.0': + resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1887,8 +1890,8 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} - '@speed-highlight/core@1.2.15': - resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@speed-highlight/core@1.2.16': + resolution: {integrity: sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1926,50 +1929,50 @@ packages: '@types/wicg-file-system-access@2020.9.8': resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-7t6tPrvxiHTCL8Ye8H/XZDceUB/UTXerghIEBiVjoEvPK6AYxxqOB0vEhKGRhlCkUd3kQAcKQuda/SK63hBkTQ==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-X6NqNmoTnO1vtcsE4qP571BByPi+i16Ynrp6fffcQ38pbRuy4mwECxA9nKb273gscG0wvcIcGM81B+vy1F4nDw==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-pCzBIEDQTDaXdGdv6tQkPdnvnyLdIhSApeTjP86rvm3bfNpMbSi0DkDSQoJvEeZWYDw0ewe7TTDhbgtTSmqegQ==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-VeguHC/F7EbxNPCrcwCRH2wif6PZKcdUg2DtMyjxohtcVK3vOoVCYqhJBgJVWQ2gbXk+bXcIz8L2/uNYDksN0w==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-OAJi5GpueqRVGrtDn/N8uNRriD1OX1jvJf3GyYNMKdWw4ZAArYKAmLU0ZY4rEDj4oIV2RAHf1IGVQesCBcWqIw==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-19TXmRxxPUNNDAP/4xBwDNud1NvuNgQJhRm87t7/CSh4FGhWeeEm7AuyEvrCB+vzeeI/ExT+J58U1HOo+kYKzg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-8cFWEOlCcVh8pPiqkXkjgNqPJgOk3VLtYKijMh9KAPaDFu3EBfV9pp68uteTajljPmZdX5zYkfcMzQly2RjMzg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-szcSb7sSn3OQZ5UWtToG2RTQeDlokd05pXM+rGctBIctDj6E4j9bvAfp6xLZCzqyDuZJhi3cXCJBdSlBD0NjHw==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-TGdfapfCFj4hl9M1/5oG6wjjhfoFviiMLru3a7ib63ozQwMDraJNqPrJuQ0tvVAoSMJb1wtVbvg7yYMml/KPLg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-h5Etd2Kc6lvvc+X4MU/EFeYDUZAS4hOqB18piWuy2INHfRvJMOo8jZOwUJRix3NQu75tPbltFCkwFOGqB0fd2Q==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-tOEn/EC0nRnz7JHfDWwXmmBkU4dt8wdU/jYyJTOlkEhxfJtAJTjVmo4AUHwzLyV9KGIzvfymTKrQPTnD0p9ACA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-JUUFHNi3asye8iOlVmFEra34LuQ8bw8qvYWp+cW7EXg3mrMxtac/u5chhi8BmICEav2Ff3UFd8ZFL0n+F5EvBA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-SBWFFs+MglgDrx8lh37uFHVQcljN09Z/D8Z+YwykMVKtYEIGM/i5WzS8Xpiegm9Vcqu3HTkuAfjQfy6X5MsqnA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-1ZPtOctecgkUHBIEoTefE34t4CewqxwdzIkRx22fDEC9bHhN8xO4JSfQyL+OSWDxiZaJKuqR9BnfWSl2eAFtmg==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260607.1': - resolution: {integrity: sha512-+DBR9iyRZiUNhJI2CqFluJLxKl3xkBGFFCADiy63pCpLVCCNuffunRhcxQ2vhnLnM3dBDoqOHE4ekKg0GfaS/Q==} + '@typescript/native-preview@7.0.0-dev.20260611.2': + resolution: {integrity: sha512-nP/OrQRFTRKHeQiXzMVhQlxSrOPeuDgeGGd9KMlmOFTc/bbQ8Zd6Ep5izsdTkFljd26QUfpm98crp5E5bYAvJg==} engines: {node: '>=16.20.0'} hasBin: true @@ -2776,8 +2779,8 @@ packages: resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} hasBin: true - miniflare@4.20260603.0: - resolution: {integrity: sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==} + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} engines: {node: '>=22.0.0'} hasBin: true @@ -2827,9 +2830,9 @@ packages: resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} engines: {node: '>=10'} - node-web-audio-api@1.0.9: - resolution: {integrity: sha512-udyabeRtRcnxUx4XWvvSAvwTqsAUHAawiVWg0IHFSUAPD8m+5cAAL6RPXgF7BOHxPmJq2s77ceBF4tlG45K0/Q==} - engines: {node: '>= 14'} + node-web-audio-api@2.0.0: + resolution: {integrity: sha512-I8r10N26N4ssu7KbL70ywWZIVu5F6eSas/DO1wHWwLjiBSo7TKSOXgCVd0mfEvrewDY5vkmqGCUL3ix/iMr4yA==} + engines: {node: '>= 22'} object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} @@ -2866,8 +2869,8 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - oxfmt@0.52.0: - resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} + oxfmt@0.54.0: + resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2879,8 +2882,8 @@ packages: vite-plus: optional: true - oxlint@1.67.0: - resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} + oxlint@1.69.0: + resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3172,6 +3175,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3566,9 +3574,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} @@ -3584,17 +3592,17 @@ packages: engines: {node: '>=8'} hasBin: true - workerd@1.20260603.1: - resolution: {integrity: sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==} + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} engines: {node: '>=16'} hasBin: true - wrangler@4.98.0: - resolution: {integrity: sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==} + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260603.1 + '@cloudflare/workers-types': ^4.20260611.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3811,28 +3819,28 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260603.1 + workerd: 1.20260611.1 - '@cloudflare/workerd-darwin-64@1.20260603.1': + '@cloudflare/workerd-darwin-64@1.20260611.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260603.1': + '@cloudflare/workerd-darwin-arm64@1.20260611.1': optional: true - '@cloudflare/workerd-linux-64@1.20260603.1': + '@cloudflare/workerd-linux-64@1.20260611.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260603.1': + '@cloudflare/workerd-linux-arm64@1.20260611.1': optional: true - '@cloudflare/workerd-windows-64@1.20260603.1': + '@cloudflare/workerd-windows-64@1.20260611.1': optional: true - '@cloudflare/workers-types@4.20260608.1': {} + '@cloudflare/workers-types@4.20260611.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -4252,6 +4260,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4271,118 +4286,118 @@ snapshots: '@oxc-project/types@0.134.0': {} - '@oxfmt/binding-android-arm-eabi@0.52.0': + '@oxfmt/binding-android-arm-eabi@0.54.0': optional: true - '@oxfmt/binding-android-arm64@0.52.0': + '@oxfmt/binding-android-arm64@0.54.0': optional: true - '@oxfmt/binding-darwin-arm64@0.52.0': + '@oxfmt/binding-darwin-arm64@0.54.0': optional: true - '@oxfmt/binding-darwin-x64@0.52.0': + '@oxfmt/binding-darwin-x64@0.54.0': optional: true - '@oxfmt/binding-freebsd-x64@0.52.0': + '@oxfmt/binding-freebsd-x64@0.54.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.54.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + '@oxfmt/binding-linux-arm-musleabihf@0.54.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.52.0': + '@oxfmt/binding-linux-arm64-gnu@0.54.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.52.0': + '@oxfmt/binding-linux-arm64-musl@0.54.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + '@oxfmt/binding-linux-ppc64-gnu@0.54.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + '@oxfmt/binding-linux-riscv64-gnu@0.54.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.52.0': + '@oxfmt/binding-linux-riscv64-musl@0.54.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.52.0': + '@oxfmt/binding-linux-s390x-gnu@0.54.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.52.0': + '@oxfmt/binding-linux-x64-gnu@0.54.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.52.0': + '@oxfmt/binding-linux-x64-musl@0.54.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.52.0': + '@oxfmt/binding-openharmony-arm64@0.54.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.52.0': + '@oxfmt/binding-win32-arm64-msvc@0.54.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.52.0': + '@oxfmt/binding-win32-ia32-msvc@0.54.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.52.0': + '@oxfmt/binding-win32-x64-msvc@0.54.0': optional: true - '@oxlint/binding-android-arm-eabi@1.67.0': + '@oxlint/binding-android-arm-eabi@1.69.0': optional: true - '@oxlint/binding-android-arm64@1.67.0': + '@oxlint/binding-android-arm64@1.69.0': optional: true - '@oxlint/binding-darwin-arm64@1.67.0': + '@oxlint/binding-darwin-arm64@1.69.0': optional: true - '@oxlint/binding-darwin-x64@1.67.0': + '@oxlint/binding-darwin-x64@1.69.0': optional: true - '@oxlint/binding-freebsd-x64@1.67.0': + '@oxlint/binding-freebsd-x64@1.69.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + '@oxlint/binding-linux-arm-gnueabihf@1.69.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.67.0': + '@oxlint/binding-linux-arm-musleabihf@1.69.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.67.0': + '@oxlint/binding-linux-arm64-gnu@1.69.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.67.0': + '@oxlint/binding-linux-arm64-musl@1.69.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.67.0': + '@oxlint/binding-linux-ppc64-gnu@1.69.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.67.0': + '@oxlint/binding-linux-riscv64-gnu@1.69.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.67.0': + '@oxlint/binding-linux-riscv64-musl@1.69.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.67.0': + '@oxlint/binding-linux-s390x-gnu@1.69.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.67.0': + '@oxlint/binding-linux-x64-gnu@1.69.0': optional: true - '@oxlint/binding-linux-x64-musl@1.67.0': + '@oxlint/binding-linux-x64-musl@1.69.0': optional: true - '@oxlint/binding-openharmony-arm64@1.67.0': + '@oxlint/binding-openharmony-arm64@1.69.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.67.0': + '@oxlint/binding-win32-arm64-msvc@1.69.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.67.0': + '@oxlint/binding-win32-ia32-msvc@1.69.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.67.0': + '@oxlint/binding-win32-x64-msvc@1.69.0': optional: true '@pinojs/redact@0.4.0': {} @@ -4517,7 +4532,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-wasm32-wasi@1.0.3': @@ -4650,7 +4665,7 @@ snapshots: '@sindresorhus/is@7.2.0': {} - '@speed-highlight/core@1.2.15': {} + '@speed-highlight/core@1.2.16': {} '@standard-schema/spec@1.1.0': {} @@ -4684,36 +4699,36 @@ snapshots: '@types/wicg-file-system-access@2020.9.8': {} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260607.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260607.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260607.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260607.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260607.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260607.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260607.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260611.2': optional: true - '@typescript/native-preview@7.0.0-dev.20260607.1': + '@typescript/native-preview@7.0.0-dev.20260611.2': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260607.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260607.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260607.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260607.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260607.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260607.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260607.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260611.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260611.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260611.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260611.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260611.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260611.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260611.2 '@uwx/libav.js-fat@6.0.0-nightly.29.f420ff.ffmpeg.6.1.1': {} @@ -5604,12 +5619,12 @@ snapshots: bn.js: 4.12.3 brorand: 1.1.0 - miniflare@4.20260603.0: + miniflare@4.20260611.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 undici: 7.24.8 - workerd: 1.20260603.1 + workerd: 1.20260611.1 ws: 8.20.1 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -5679,11 +5694,11 @@ snapshots: util: 0.12.5 vm-browserify: 1.1.2 - node-web-audio-api@1.0.9: + node-web-audio-api@2.0.0: dependencies: caller: 1.1.0 node-fetch: 3.3.2 - webidl-conversions: 7.0.0 + webidl-conversions: 8.0.1 object-inspect@1.13.4: {} @@ -5722,51 +5737,51 @@ snapshots: outdent@0.5.0: {} - oxfmt@0.52.0: + oxfmt@0.54.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.52.0 - '@oxfmt/binding-android-arm64': 0.52.0 - '@oxfmt/binding-darwin-arm64': 0.52.0 - '@oxfmt/binding-darwin-x64': 0.52.0 - '@oxfmt/binding-freebsd-x64': 0.52.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 - '@oxfmt/binding-linux-arm64-gnu': 0.52.0 - '@oxfmt/binding-linux-arm64-musl': 0.52.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-musl': 0.52.0 - '@oxfmt/binding-linux-s390x-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-musl': 0.52.0 - '@oxfmt/binding-openharmony-arm64': 0.52.0 - '@oxfmt/binding-win32-arm64-msvc': 0.52.0 - '@oxfmt/binding-win32-ia32-msvc': 0.52.0 - '@oxfmt/binding-win32-x64-msvc': 0.52.0 - - oxlint@1.67.0: + '@oxfmt/binding-android-arm-eabi': 0.54.0 + '@oxfmt/binding-android-arm64': 0.54.0 + '@oxfmt/binding-darwin-arm64': 0.54.0 + '@oxfmt/binding-darwin-x64': 0.54.0 + '@oxfmt/binding-freebsd-x64': 0.54.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.54.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.54.0 + '@oxfmt/binding-linux-arm64-gnu': 0.54.0 + '@oxfmt/binding-linux-arm64-musl': 0.54.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.54.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.54.0 + '@oxfmt/binding-linux-riscv64-musl': 0.54.0 + '@oxfmt/binding-linux-s390x-gnu': 0.54.0 + '@oxfmt/binding-linux-x64-gnu': 0.54.0 + '@oxfmt/binding-linux-x64-musl': 0.54.0 + '@oxfmt/binding-openharmony-arm64': 0.54.0 + '@oxfmt/binding-win32-arm64-msvc': 0.54.0 + '@oxfmt/binding-win32-ia32-msvc': 0.54.0 + '@oxfmt/binding-win32-x64-msvc': 0.54.0 + + oxlint@1.69.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.67.0 - '@oxlint/binding-android-arm64': 1.67.0 - '@oxlint/binding-darwin-arm64': 1.67.0 - '@oxlint/binding-darwin-x64': 1.67.0 - '@oxlint/binding-freebsd-x64': 1.67.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 - '@oxlint/binding-linux-arm-musleabihf': 1.67.0 - '@oxlint/binding-linux-arm64-gnu': 1.67.0 - '@oxlint/binding-linux-arm64-musl': 1.67.0 - '@oxlint/binding-linux-ppc64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-musl': 1.67.0 - '@oxlint/binding-linux-s390x-gnu': 1.67.0 - '@oxlint/binding-linux-x64-gnu': 1.67.0 - '@oxlint/binding-linux-x64-musl': 1.67.0 - '@oxlint/binding-openharmony-arm64': 1.67.0 - '@oxlint/binding-win32-arm64-msvc': 1.67.0 - '@oxlint/binding-win32-ia32-msvc': 1.67.0 - '@oxlint/binding-win32-x64-msvc': 1.67.0 + '@oxlint/binding-android-arm-eabi': 1.69.0 + '@oxlint/binding-android-arm64': 1.69.0 + '@oxlint/binding-darwin-arm64': 1.69.0 + '@oxlint/binding-darwin-x64': 1.69.0 + '@oxlint/binding-freebsd-x64': 1.69.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.69.0 + '@oxlint/binding-linux-arm-musleabihf': 1.69.0 + '@oxlint/binding-linux-arm64-gnu': 1.69.0 + '@oxlint/binding-linux-arm64-musl': 1.69.0 + '@oxlint/binding-linux-ppc64-gnu': 1.69.0 + '@oxlint/binding-linux-riscv64-gnu': 1.69.0 + '@oxlint/binding-linux-riscv64-musl': 1.69.0 + '@oxlint/binding-linux-s390x-gnu': 1.69.0 + '@oxlint/binding-linux-x64-gnu': 1.69.0 + '@oxlint/binding-linux-x64-musl': 1.69.0 + '@oxlint/binding-openharmony-arm64': 1.69.0 + '@oxlint/binding-win32-arm64-msvc': 1.69.0 + '@oxlint/binding-win32-ia32-msvc': 1.69.0 + '@oxlint/binding-win32-x64-msvc': 1.69.0 p-filter@2.1.0: dependencies: @@ -5985,7 +6000,7 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - rolldown-plugin-dts@0.25.2(@typescript/native-preview@7.0.0-dev.20260607.1)(rolldown@1.1.0): + rolldown-plugin-dts@0.25.2(@typescript/native-preview@7.0.0-dev.20260611.2)(rolldown@1.1.0): dependencies: '@babel/generator': 8.0.0-rc.6 '@babel/helper-validator-identifier': 8.0.0-rc.6 @@ -5997,7 +6012,7 @@ snapshots: obug: 2.1.1 rolldown: 1.1.0 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260607.1 + '@typescript/native-preview': 7.0.0-dev.20260611.2 transitivePeerDependencies: - oxc-resolver @@ -6118,6 +6133,8 @@ snapshots: semver@7.8.1: {} + semver@7.8.4: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6139,7 +6156,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.1 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -6322,7 +6339,7 @@ snapshots: transitivePeerDependencies: - supports-color - tsdown@0.22.2(@typescript/native-preview@7.0.0-dev.20260607.1)(tsx@4.22.4)(unrun@0.2.37): + tsdown@0.22.2(@typescript/native-preview@7.0.0-dev.20260611.2)(tsx@4.22.4)(unrun@0.2.37): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -6333,7 +6350,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.1.0 - rolldown-plugin-dts: 0.25.2(@typescript/native-preview@7.0.0-dev.20260607.1)(rolldown@1.1.0) + rolldown-plugin-dts: 0.25.2(@typescript/native-preview@7.0.0-dev.20260611.2)(rolldown@1.1.0) semver: 7.8.1 tinyexec: 1.2.4 tinyglobby: 0.2.17 @@ -6476,7 +6493,7 @@ snapshots: web-streams-polyfill@3.3.3: {} - webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} which-typed-array@1.1.20: dependencies: @@ -6497,26 +6514,26 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20260603.1: + workerd@1.20260611.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260603.1 - '@cloudflare/workerd-darwin-arm64': 1.20260603.1 - '@cloudflare/workerd-linux-64': 1.20260603.1 - '@cloudflare/workerd-linux-arm64': 1.20260603.1 - '@cloudflare/workerd-windows-64': 1.20260603.1 + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 - wrangler@4.98.0(@cloudflare/workers-types@4.20260608.1): + wrangler@4.100.0(@cloudflare/workers-types@4.20260611.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) blake3-wasm: 2.1.5 esbuild: 0.27.3 - miniflare: 4.20260603.0 + miniflare: 4.20260611.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260603.1 + workerd: 1.20260611.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260608.1 + '@cloudflare/workers-types': 4.20260611.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -6537,6 +6554,6 @@ snapshots: dependencies: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.15 + '@speed-highlight/core': 1.2.16 cookie: 1.1.1 youch-core: 0.3.3 diff --git a/scripts/build-sea.ts b/scripts/build-sea.ts index 12e76a5d..8d8030c1 100644 --- a/scripts/build-sea.ts +++ b/scripts/build-sea.ts @@ -1,10 +1,11 @@ -import { chmod, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; -import { builtinModules } from 'node:module'; -import { dirname, isAbsolute, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { builtinModules, createRequire } from 'node:module'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { build } from 'vite'; +import { build, defaultServerConditions, defaultServerMainFields } from 'vite'; const execFileAsync = promisify(execFile); @@ -21,9 +22,32 @@ interface CliArgs { bundleOnly: boolean; } +interface EmbeddedNodeModuleRoot { + /** Package embedded into the binary together with its production dependency closure. */ + name: string; + /** Directory (relative to the repository root) whose dependency resolution finds the package. */ + resolveFrom: string; + /** Files of this package (matched against the package-relative posix path) excluded from embedding. */ + excludeFilePatterns?: RegExp[]; +} + interface SeaTargetConfig { packageDir: string; outputBaseName: string; + /** Bundle entry relative to `packageDir` (default: `src/cli.ts`). */ + entry?: string; + /** + * Embed the bundle itself as the `sea-entry.cjs` asset so the running binary can spawn eval workers that + * re-execute it (worker scripts cannot be loaded from disk in a SEA). Requires an entry that dispatches on + * the workerData role marker, like `player-tui/src/sea-main.ts`. + */ + embedBundleAsset?: boolean; + /** + * Optional packages (native addons and friends) embedded as SEA assets. The running binary extracts them + * to a cache directory at startup — native addons cannot be loaded from memory — and resolves them through + * `loadOptionalNodeModule`; see `player-tui/src/node/sea-embedded-modules.ts`. + */ + embeddedNodeModules?: EmbeddedNodeModuleRoot[]; optionalExternalModules?: string[]; bundleBanner?: string; aliases?: Record; @@ -39,6 +63,19 @@ const SEA_TARGETS: Record = { player: { packageDir: resolve(repositoryDir, 'packages/player-tui'), outputBaseName: 'be-music-player', + entry: 'src/sea-main.ts', + embedBundleAsset: true, + embeddedNodeModules: [ + { name: 'node-web-audio-api', resolveFrom: 'packages/player' }, + { + name: '@uwx/libav.js-fat', + resolveFrom: 'packages/player-tui', + // Node always selects the `.wasm` build (see the target() probe in dist/libav-fat.js) and the player + // initialises libav with `noworker: true`, so the asm.js fallback (~122MB), the worker-threaded + // build (~35MB), and the LGPL source tarballs (~30MB) are dead weight for the SEA runtime. + excludeFilePatterns: [/\.asm\.(js|mjs)$/, /\.thr\./, /\.d\.ts$/, /^sources\//], + }, + ], optionalExternalModules: ['node-web-audio-api', '@uwx/libav.js-fat'], bundleBanner: SEA_WORKER_BANNER, aliases: { @@ -63,6 +100,7 @@ const SEA_TARGETS: Record = { '@be-music/utils/cli-path': resolve(repositoryDir, 'packages/utils/src/cli-path.ts'), '@be-music/utils/core': resolve(repositoryDir, 'packages/utils/src/core.ts'), '@be-music/utils/log': resolve(repositoryDir, 'packages/utils/src/log.ts'), + '@be-music/utils/optional-node-module': resolve(repositoryDir, 'packages/utils/src/optional-node-module.ts'), '@be-music/utils/path': resolve(repositoryDir, 'packages/utils/src/path.ts'), '@be-music/utils/pcm': resolve(repositoryDir, 'packages/utils/src/pcm.ts'), '@be-music/utils/workerize': resolve(repositoryDir, 'packages/utils/src/workerize.ts'), @@ -82,6 +120,7 @@ const SEA_TARGETS: Record = { '@be-music/utils/cli-path': resolve(repositoryDir, 'packages/utils/src/cli-path.ts'), '@be-music/utils/core': resolve(repositoryDir, 'packages/utils/src/core.ts'), '@be-music/utils/log': resolve(repositoryDir, 'packages/utils/src/log.ts'), + '@be-music/utils/optional-node-module': resolve(repositoryDir, 'packages/utils/src/optional-node-module.ts'), '@be-music/utils/path': resolve(repositoryDir, 'packages/utils/src/path.ts'), '@be-music/utils/pcm': resolve(repositoryDir, 'packages/utils/src/pcm.ts'), '@be-music/utils/workerize': resolve(repositoryDir, 'packages/utils/src/workerize.ts'), @@ -234,11 +273,14 @@ async function buildSeaBundle(config: SeaTargetConfig, seaDir: string): Promise< const workspaceAliasPlugin = createWorkspaceAliasPlugin(config.aliases); await build({ configFile: false, - resolve: config.aliases - ? { - alias: config.aliases, - } - : undefined, + resolve: { + ...(config.aliases ? { alias: config.aliases } : {}), + // The SEA bundle runs in Node, but `build()` uses the client environment whose default conditions and + // main fields prefer `browser` entries. That silently swapped in pino/browser (whose `flush` is a noop, + // hanging the logger close and writing no NDJSON) and isoworker's Web Worker implementation. + conditions: [...defaultServerConditions], + mainFields: [...defaultServerMainFields], + }, build: { target: 'node25', outDir: seaDir, @@ -247,7 +289,7 @@ async function buildSeaBundle(config: SeaTargetConfig, seaDir: string): Promise< minify: false, sourcemap: false, lib: { - entry: resolve(config.packageDir, 'src/cli.ts'), + entry: resolve(config.packageDir, config.entry ?? 'src/cli.ts'), formats: ['cjs'], fileName: () => 'sea-entry.cjs', }, @@ -263,70 +305,175 @@ async function buildSeaBundle(config: SeaTargetConfig, seaDir: string): Promise< }); } -function replaceLocalChunkRequires(code: string, localChunkIds: Set): string { - return code.replace(/require\((['"])(\.\/[^'"]+)\1\)/g, (match, _quote, id) => - localChunkIds.has(id) ? `__sea_require(${JSON.stringify(id)})` : match, - ); -} - -function indentBlock(code: string): string { - return code - .split('\n') - .map((line) => (line.length > 0 ? ` ${line}` : '')) - .join('\n'); -} - async function inlineSeaRelativeChunks(seaDir: string): Promise { const entryFileName = 'sea-entry.cjs'; const seaFiles = await readdir(seaDir); - const localChunkFileNames = seaFiles.filter((fileName) => fileName.endsWith('.cjs') && fileName !== entryFileName); + const localChunkFileNames = seaFiles.filter( + (fileName) => (fileName.endsWith('.cjs') || fileName.endsWith('.js')) && fileName !== entryFileName, + ); if (localChunkFileNames.length === 0) { return; } - const localChunkIds = new Set(localChunkFileNames.map((fileName) => `./${fileName}`)); const localChunkSources = await Promise.all( - localChunkFileNames.map(async (fileName) => { - const chunkPath = resolve(seaDir, fileName); - const chunkCode = await readFile(chunkPath, 'utf8'); - return { - fileName, - code: replaceLocalChunkRequires(chunkCode, localChunkIds), - }; - }), + localChunkFileNames.map(async (fileName) => ({ + fileName, + code: await readFile(resolve(seaDir, fileName), 'utf8'), + })), ); - const entryPath = resolve(seaDir, entryFileName); - const entryCode = replaceLocalChunkRequires(await readFile(entryPath, 'utf8'), localChunkIds); - const inlinedRuntime = [ + const entryCode = await readFile(entryPath, 'utf8'); + + // Chunk and entry sources are embedded verbatim: any source transformation (re-indenting, rewriting + // `require("./chunk")` calls with a regex) can corrupt multi-line template literals that carry binary + // payloads — the yEnc-encoded decoder wasm of `@wasm-audio-decoders/*` broke exactly that way. Local + // chunk requires are redirected by shadowing `require` instead: chunk factories receive `__sea_require` + // as their `require` parameter, and the entry is wrapped in an IIFE whose `require` parameter shadows + // the SEA-injected one. `__sea_require` falls back to the real `require` for non-chunk ids. + const inlinedBundle = [ 'const __sea_modules = Object.create(null);', 'const __sea_module_cache = Object.create(null);', 'function __sea_require(id) {', - ' const cached = __sea_module_cache[id];', - ' if (cached) {', - ' return cached.exports;', - ' }', ' const factory = __sea_modules[id];', ' if (!factory) {', ' return require(id);', ' }', + ' const cached = __sea_module_cache[id];', + ' if (cached) {', + ' return cached.exports;', + ' }', ' const module = { exports: {} };', ' __sea_module_cache[id] = module;', ' factory(module, module.exports, __sea_require);', ' return module.exports;', '}', ...localChunkSources.flatMap(({ fileName, code }) => [ - `__sea_modules[${JSON.stringify(`./${fileName}`)}] = (module, exports, __sea_require) => {`, - indentBlock(code), + `__sea_modules[${JSON.stringify(`./${fileName}`)}] = (module, exports, require) => {`, + code, '};', ]), + '(function(require) {', + entryCode, + '})(__sea_require);', '', ].join('\n'); - await writeFile(entryPath, `${inlinedRuntime}${entryCode}`, 'utf8'); + await writeFile(entryPath, inlinedBundle, 'utf8'); await Promise.all(localChunkFileNames.map((fileName) => unlink(resolve(seaDir, fileName)))); } +// Asset names kept in sync with packages/player-tui/src/node/sea-embedded-modules.ts. +const EMBEDDED_MODULES_MANIFEST_ASSET = 'embedded-node-modules.json'; +const EMBEDDED_MODULE_ASSET_PREFIX = 'embedded-node-modules/'; +// The SEA binary targets the build host platform, so only the matching native addon is embedded. +const NATIVE_ADDON_PLATFORM_TAG = `${process.platform}-${process.arch}`; + +interface EmbeddedModulesBuildResult { + /** Asset name → absolute source file path, ready to merge into the SEA config `assets` map. */ + assets: Record; + manifest: { cacheKey: string; files: string[] }; +} + +async function resolvePackageDir(name: string, fromDir: string): Promise { + const requireFromBase = createRequire(join(fromDir, 'package.json')); + try { + return dirname(await realpath(requireFromBase.resolve(`${name}/package.json`))); + } catch { + // The package `exports` map may hide package.json; resolve the entry file and walk up to the package root. + let dir = dirname(await realpath(requireFromBase.resolve(name))); + while (true) { + try { + const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name?: string }; + if (manifest.name === name) { + return dir; + } + } catch { + // No package.json at this level; keep walking up. + } + const parent = dirname(dir); + if (parent === dir) { + throw new Error(`Cannot locate the package directory of ${name}`); + } + dir = parent; + } + } +} + +async function collectEmbeddedPackageDirs(roots: EmbeddedNodeModuleRoot[]): Promise> { + const packageDirs = new Map(); + const queue = roots.map((root) => ({ name: root.name, fromDir: resolve(repositoryDir, root.resolveFrom) })); + while (queue.length > 0) { + const next = queue.shift(); + if (!next || packageDirs.has(next.name)) { + continue; + } + const packageDir = await resolvePackageDir(next.name, next.fromDir); + packageDirs.set(next.name, packageDir); + const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + for (const dependencyName of Object.keys(manifest.dependencies ?? {})) { + queue.push({ name: dependencyName, fromDir: packageDir }); + } + } + return packageDirs; +} + +function shouldEmbedPackageFile(relativePosixPath: string, excludeFilePatterns: RegExp[] | undefined): boolean { + const fileName = relativePosixPath.split('/').at(-1) ?? relativePosixPath; + if (fileName.endsWith('.node') && !fileName.includes(NATIVE_ADDON_PLATFORM_TAG)) { + return false; + } + return !excludeFilePatterns?.some((pattern) => pattern.test(relativePosixPath)); +} + +async function listEmbeddedPackageFiles( + packageDir: string, + excludeFilePatterns: RegExp[] | undefined, + currentDir = packageDir, +): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const entryPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules') { + continue; + } + files.push(...(await listEmbeddedPackageFiles(packageDir, excludeFilePatterns, entryPath))); + continue; + } + const isFile = entry.isFile() || (entry.isSymbolicLink() && (await stat(entryPath)).isFile()); + const relativePosixPath = relative(packageDir, entryPath).replaceAll('\\', '/'); + if (isFile && shouldEmbedPackageFile(relativePosixPath, excludeFilePatterns)) { + files.push(relativePosixPath); + } + } + return files.sort(); +} + +async function buildEmbeddedNodeModuleAssets(roots: EmbeddedNodeModuleRoot[]): Promise { + const packageDirs = await collectEmbeddedPackageDirs(roots); + const excludeFilePatternsByName = new Map(roots.map((root) => [root.name, root.excludeFilePatterns])); + const assets: Record = {}; + const manifestFiles: string[] = []; + const cacheKeyHash = createHash('sha256'); + for (const [name, packageDir] of [...packageDirs.entries()].sort(([a], [b]) => a.localeCompare(b))) { + for (const relativePath of await listEmbeddedPackageFiles(packageDir, excludeFilePatternsByName.get(name))) { + const manifestPath = `${name}/${relativePath}`; + const filePath = join(packageDir, ...relativePath.split('/')); + assets[`${EMBEDDED_MODULE_ASSET_PREFIX}${manifestPath}`] = filePath; + manifestFiles.push(manifestPath); + cacheKeyHash.update(manifestPath); + cacheKeyHash.update(await readFile(filePath)); + } + } + return { + assets, + manifest: { cacheKey: cacheKeyHash.digest('hex').slice(0, 16), files: manifestFiles }, + }; +} + async function supportsNodeFlag(nodeBinaryPath: string, cwd: string, flag: string): Promise { try { const { stdout, stderr } = await execFileAsync(nodeBinaryPath, ['--help'], { cwd }); @@ -364,8 +511,15 @@ async function maybeAdhocSignMacBinary(cwd: string, pathValue: string): Promise< } try { await execFileAsync('codesign', ['--sign', '-', '--force', pathValue], { cwd }); - } catch { - // Ad-hoc signing is best-effort for local execution. + } catch (error) { + // Ad-hoc signing is best-effort, but an unsigned SEA binary is killed by macOS with SIGKILL + // ("Killed: 9") at launch — surface the failure instead of producing a silently broken executable. + const message = error instanceof Error && error.message ? error.message : String(error); + process.stderr.write( + `Warning: ad-hoc code signing failed (${message.trim()}).\n` + + `The generated binary will likely be killed by macOS at launch. Sign it manually with:\n` + + ` codesign --sign - --force ${pathValue}\n`, + ); } } @@ -386,6 +540,19 @@ async function main(): Promise { await buildSeaBundle(targetConfig, seaDir); await inlineSeaRelativeChunks(seaDir); + let embeddedModules: EmbeddedModulesBuildResult | undefined; + const embeddedManifestPath = resolve(seaDir, EMBEDDED_MODULES_MANIFEST_ASSET); + if (targetConfig.embeddedNodeModules && targetConfig.embeddedNodeModules.length > 0) { + process.stdout.write('Collecting embedded node modules...\n'); + embeddedModules = await buildEmbeddedNodeModuleAssets(targetConfig.embeddedNodeModules); + await writeFile(embeddedManifestPath, `${JSON.stringify(embeddedModules.manifest, null, 2)}\n`, 'utf8'); + } + + const assets: Record = { + // Asset name kept in sync with SEA_BUNDLE_ASSET_NAME in packages/player-tui/src/node/sea-worker.ts. + ...(targetConfig.embedBundleAsset ? { 'sea-entry.cjs': bundlePath } : {}), + ...(embeddedModules ? { [EMBEDDED_MODULES_MANIFEST_ASSET]: embeddedManifestPath, ...embeddedModules.assets } : {}), + }; const seaConfig = { main: bundlePath, mainFormat: 'commonjs', @@ -393,6 +560,7 @@ async function main(): Promise { executable: nodeBinaryPath, disableExperimentalSEAWarning: true, useCodeCache: true, + ...(Object.keys(assets).length > 0 ? { assets } : {}), }; await writeFile(configPath, `${JSON.stringify(seaConfig, null, 2)}\n`, 'utf8'); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index dc123cd4..4fd87fcc 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -18,6 +18,7 @@ "@be-music/utils/core": ["./packages/utils/src/core.ts"], "@be-music/utils/cli-path": ["./packages/utils/src/cli-path.ts"], "@be-music/utils/log": ["./packages/utils/src/log.ts"], + "@be-music/utils/optional-node-module": ["./packages/utils/src/optional-node-module.ts"], "@be-music/utils/path": ["./packages/utils/src/path.ts"], "@be-music/utils/pcm": ["./packages/utils/src/pcm.ts"], "@be-music/utils/workerize": ["./packages/utils/src/workerize.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 55946674..73879e30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,10 @@ const workspaceAliases = [ { find: '@be-music/utils/core', replacement: resolve(rootDir, 'packages/utils/src/core.ts') }, { find: '@be-music/utils/cli-path', replacement: resolve(rootDir, 'packages/utils/src/cli-path.ts') }, { find: '@be-music/utils/log', replacement: resolve(rootDir, 'packages/utils/src/log.ts') }, + { + find: '@be-music/utils/optional-node-module', + replacement: resolve(rootDir, 'packages/utils/src/optional-node-module.ts'), + }, { find: '@be-music/utils/path', replacement: resolve(rootDir, 'packages/utils/src/path.ts') }, { find: '@be-music/utils/pcm', replacement: resolve(rootDir, 'packages/utils/src/pcm.ts') }, { find: '@be-music/utils/workerize', replacement: resolve(rootDir, 'packages/utils/src/workerize.ts') },