@helix3/helix-cli 0.1.18-helix3.352 → 0.1.18-helix3.353

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helix3/helix-cli",
3
- "version": "0.1.18-helix3.352",
3
+ "version": "0.1.18-helix3.353",
4
4
  "description": "helix — the HELIX creator CLI: scaffold, validate, and publish Instant Worlds",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",
@@ -65,6 +65,19 @@ Selected tires lower one exact common or vehicle-local leaf per axle. Pressure,
65
65
 
66
66
  Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Raw Lua/controller and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant. Bridge currently seals one canonical source GLB and truthfully maps every tier to it; optimized mesh/texture rendition IDs must not be selected until the optimizer has materialized and sealed those artifacts.
67
67
 
68
+ ## Engine recordings
69
+
70
+ BeamNG names an engine sound with `soundConfig.sampleName` (and `soundConfigExhaust.sampleName`), which resolves to the mod's `art/sound/blends/<sampleName>.sfxBlend2D.json`. That blend is the mapping from recordings to rpm: `samples` is a list of load rows, each an rpm-ordered ladder of `["art/sound/engine/<set>/<file>.wav", rpm]`. (There is no `.sbeng` format in BeamNG; the blend JSON is the whole definition.) `lower-beamng.py` (via `bin/beamng_audio.py`) reads it and emits, for a mod that ships its own recordings:
71
+
72
+ - `<out>/audio/<name>.wav` for every recording the selected blend names. A 16-bit PCM WAV at 44.1 or 48 kHz is copied byte for byte. OGG, FLAC, 24-bit or other-rate WAVs are transcoded with `ffmpeg` (`pcm_s16le`, channels kept, one bank sample rate: the most common one among the files); the import is **refused with a clear message when ffmpeg is needed and not installed** (`HELIX_FFMPEG` overrides the lookup). Bare-number file names are prefixed with their folder (`PSH_1768.wav`).
73
+ - `audio.bank.blends.<sampleName>`: the source's own ladders, `[[file, rpm], ...]`, rpm values copied from the blend (sorted ascending; a repeated rpm makes the blend unusable rather than guessed at).
74
+ - `audio.bank.assets[]`: `{ name, file, path: "audio/<name>", role: "vehicle.audio.bank.sample", mediaType: "audio/wav", bytes, checksumSha256, cid }` and **no `url`**. The url is the public address of the sealed object, so `helix vehicle publish` uploads each file (found at `<package JSON dir>/<path>`) and fills it. The engine validator rejects a row with checksum/bytes but no url, so this plugin validates with a `pending-publish://` placeholder and emits the rows untouched; `helix vehicle validate` on an unpublished package reports exactly those url errors.
75
+ - `engineBlend` / `exhaustBlend`, `sampleCylinderCount` from `fundamentalFrequencyCylinderCount` (BeamNG has no field for the recorded engine's cylinder count, so it defaults to 4 and is recorded as `defaulted` provenance when the source omits it; set it by hand for a V6/V8 recording), `strokes: 4`, and `engineLadder` / `exhaustLadder` slots with `buffers: ["blend:<NAME>"]`, `consumed: true`. When the source declines a separate exhaust sample (`sampleName: "0"` or unset, as the 993 does), the one recording serves both ladders. A layer that is stock while the other ships is left out (no `exhaustBlend` naming a blend the bank does not hold) and its slot says why.
76
+
77
+ When the sampleName has no blend file inside the mod it is a stock BeamNG FMOD event whose recordings live in the game's own banks; nothing can be extracted and nothing is fabricated. Today's behaviour is kept (no `bank`, `engineBlend`/`exhaustBlend` name the event) and the slot reads `consumed: false`, `reason: "source uses BeamNG stock FMOD event <name>; no recording ships in the mod — platform fallback engine"`. A blend that names missing or path-escaping files, or an invalid ladder, is treated the same way with the cause in the reason. Starter, shut-off, horn and the other one-shot slots are not extracted: BeamNG ships those as stock FMOD events, not files.
78
+
79
+ Packaging copies the WAVs to `assets/audio/` beside `<id>.vehicle-package.json`, re-hashing each against its package row. The Bridge consumes an unpacked mod directory; unzip a mod archive first.
80
+
68
81
  ABS is the first portable controller adapter. Version 1 recognizes only the
69
82
  fingerprinted BeamNG 0.39.4 wheel-core shape: one selected `enableABS=true`, one
70
83
  unambiguous brake owner per axle, the stock 100 Hz update rate, and equal
@@ -516,8 +516,57 @@ function confinedNewAsset(assets, file) {
516
516
  return path;
517
517
  }
518
518
 
519
+ // A bank row the importer extracted carries bytes + checksum + cid but no `url`: the url is the public
520
+ // transport address of the sealed object, which only exists after `helix vehicle publish` uploads the
521
+ // file. The engine validator rejects any row with runtime fields and no url, so validate the package
522
+ // with an explicit pending placeholder and leave the emitted rows untouched.
523
+ function withPendingBankUrls(pkg) {
524
+ const rows = pkg?.audio?.bank?.assets;
525
+ if (!Array.isArray(rows)) return pkg;
526
+ return { ...pkg, audio: { ...pkg.audio, bank: { ...pkg.audio.bank, assets: rows.map((row) => (
527
+ row && row.url === undefined && typeof row.checksumSha256 === 'string'
528
+ ? { ...row, url: `pending-publish://sha256/${row.checksumSha256}` } : row)) } } };
529
+ }
530
+
531
+ // Copy the WAVs the importer extracted next to the vehicle package, re-hashing each against the row
532
+ // the (already hash-bound) package declares. The package JSON is written to `assets/`, so a row's
533
+ // `path` ("audio/<name>.wav") resolves relative to it for `helix vehicle publish`.
534
+ function packageAudioSamples(vehiclePackage, importedGlb, assets, outputDir, candidateId) {
535
+ const rows = vehiclePackage?.audio?.bank?.assets;
536
+ if (!Array.isArray(rows) || rows.length === 0) return [];
537
+ const sourceDirectory = join(dirname(importedGlb), 'audio');
538
+ const destinationDirectory = join(assets, 'audio');
539
+ if (existsSync(destinationDirectory)) {
540
+ const info = lstatSync(destinationDirectory);
541
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('BeamNG vehicle packaging audio destination must be a non-symlink directory');
542
+ } else {
543
+ mkdirSync(destinationDirectory);
544
+ }
545
+ const artifacts = [];
546
+ for (const row of rows) {
547
+ const name = row?.name;
548
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/.test(name) || row.path !== `audio/${name}`) {
549
+ throw new Error(`BeamNG vehicle packaging refused a non-portable audio sample row: ${String(name)}`);
550
+ }
551
+ const source = join(sourceDirectory, name);
552
+ if (!existsSync(source) || lstatSync(source).isSymbolicLink() || !lstatSync(source).isFile()) {
553
+ throw new Error(`BeamNG vehicle packaging cannot find the extracted audio sample ${name} beside the converted GLB`);
554
+ }
555
+ const identity = sha256FileSync(source);
556
+ if (identity.sha256 !== row.checksumSha256 || identity.sizeBytes !== row.bytes) {
557
+ throw new Error(`BeamNG vehicle packaging audio sample ${name} does not match its package checksum`);
558
+ }
559
+ const destination = confinedNewAsset(destinationDirectory, name);
560
+ copyFileSync(source, destination, constants.COPYFILE_EXCL);
561
+ artifacts.push({ kind: 'helix.package.asset', path: relative(outputDir, destination),
562
+ metadata: { role: 'vehicle-audio-bank-sample', vehicle: candidateId, mediaType: 'audio/wav',
563
+ sha256: identity.sha256, sizeBytes: identity.sizeBytes } });
564
+ }
565
+ return artifacts;
566
+ }
567
+
519
568
  function assertValidVehiclePackage(pkg, boundary) {
520
- const errors = validateVehiclePackage(pkg).filter((issue) => issue.level === 'error');
569
+ const errors = validateVehiclePackage(withPendingBankUrls(pkg)).filter((issue) => issue.level === 'error');
521
570
  if (errors.length > 0) {
522
571
  throw new Error(`${boundary} rejected invalid Vehicle Package v2:\n${errors
523
572
  .map((issue) => `- ${issue.path}: ${issue.message}`).join('\n')}`);
@@ -996,6 +1045,8 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
996
1045
  const sourcePath = join(outputDir, `.${candidate.id}.lowering-source.json`);
997
1046
  const geometryPath = join(outputDir, `.${candidate.id}.lowering-geometry.json`);
998
1047
  const bundlePath = join(outputDir, `${candidate.id}.functional-lowering.json`);
1048
+ const audioDir = join(outputDir, 'audio');
1049
+ rmSync(audioDir, { recursive: true, force: true });
999
1050
  let lowered;
1000
1051
  let bundle;
1001
1052
  try {
@@ -1005,6 +1056,7 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
1005
1056
  lowered = spawnSync('python3', [executable,
1006
1057
  '--source-root', source, '--vehicle-root', candidate.root, '--configuration', configuration,
1007
1058
  '--vehicle-source', sourcePath, '--geometry', geometryPath, '--output', bundlePath,
1059
+ '--audio-dir', audioDir,
1008
1060
  '--part-index', selectedPartIndexArtifact.path,
1009
1061
  ...(absControllerReceipt ? ['--abs-controller-receipt', absControllerReceipt] : []),
1010
1062
  ...(commonRoot ? ['--common-root', commonRoot] : []),
@@ -1099,6 +1151,7 @@ function packageVehicleSource(request) {
1099
1151
  artifacts.push({ kind: 'helix.package.asset', path: relative(request.outputDir, path),
1100
1152
  metadata: { role, schemaVersion: value.schemaVersion, vehicle: candidateId } });
1101
1153
  }
1154
+ artifacts.push(...packageAudioSamples(functional.vehiclePackage, input.path, assets, request.outputDir, candidateId));
1102
1155
  return { artifacts };
1103
1156
  }
1104
1157
 
@@ -1195,6 +1248,7 @@ async function main() {
1195
1248
  path: basename(sourceManifestPath), sha256: sourceManifestDigest.sha256 },
1196
1249
  functionalVehicle: { schemaVersion: functionalVehicle.schemaVersion,
1197
1250
  vehiclePackage: functionalVehicle.vehiclePackage.id,
1251
+ audioBankAssets: functionalVehicle.vehiclePackage.audio?.bank?.assets?.length ?? 0,
1198
1252
  explicitSelections: functionalVehicle.coverage.explicitSelections,
1199
1253
  resolvedSelectionPercent: functionalVehicle.coverage.resolvedSelectionPercent },
1200
1254
  qa,
@@ -0,0 +1,370 @@
1
+ """Extract a BeamNG mod's own engine recordings into HELIX vehicle-audio bank rows.
2
+
3
+ BeamNG names an engine sound by ``soundConfig.sampleName`` (and ``soundConfigExhaust.sampleName``).
4
+ That name resolves to ``art/sound/blends/<sampleName>.sfxBlend2D.json``. The blend is the whole
5
+ mapping from recordings to rpm::
6
+
7
+ { "eventName": "event:>Engine>default",
8
+ "samples": [ [ ["art/sound/engine/PSH/1768.wav", 1768], ["...", 2238], ... ], # load row 0
9
+ [ ["art/sound/engine/PSH/1768.wav", 1768], ... ] ] } # load row 1
10
+
11
+ Each row is an rpm-ordered ladder; the HELIX bank blend keeps the same ``[[file, rpm], ...]`` shape.
12
+
13
+ A ``sampleName`` with no blend file inside the mod is a stock game event whose recordings live in
14
+ the game's own FMOD banks. Nothing ships in the mod, so nothing can be extracted and nothing is
15
+ fabricated: the caller keeps the platform fallback engine and states that in the slot reason.
16
+
17
+ Only the mod's real bytes are ever used. A 16-bit PCM WAV is copied verbatim. OGG, FLAC and WAVs
18
+ that are not 16-bit PCM are transcoded with ``ffmpeg`` (``pcm_s16le``, channels preserved); the
19
+ conversion is refused with a clear message when ``ffmpeg`` is not installed.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import struct
29
+ import subprocess
30
+ import tempfile
31
+ from collections import Counter
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from beamng_jbeam import parse_jbeam_text
36
+
37
+ BLEND_SUFFIX = ".sfxBlend2D.json"
38
+ BLEND_DIRECTORY = ("art", "sound", "blends")
39
+ MAX_BLEND_BYTES = 1024 * 1024
40
+ MAX_SAMPLE_FILES = 400
41
+ MAX_SAMPLE_BYTES = 128 * 1024 * 1024
42
+ MAX_TOTAL_BYTES = 768 * 1024 * 1024
43
+ MAX_WALK_ENTRIES = 200_000
44
+ BANK_RATES = (44100, 48000)
45
+ DEFAULT_RATE = 44100
46
+ ASSET_ROLE = "vehicle.audio.bank.sample"
47
+ TRANSCODE_EXTENSIONS = {".ogg", ".flac", ".mp3", ".opus", ".aif", ".aiff"}
48
+ # BeamNG treats a sampleName it cannot resolve as "no sound for this layer"; modders use "0".
49
+ DISABLED_SAMPLE_NAMES = {"", "0", "none", "null", "false"}
50
+
51
+
52
+ class AudioExtractionError(ValueError):
53
+ """The mod ships audio the importer must not silently drop or guess at (refuse the import)."""
54
+
55
+
56
+ class AudioUnavailable(AudioExtractionError):
57
+ """The mod's own blend is unusable (missing files, malformed ladder, over a limit).
58
+
59
+ The importer keeps the platform fallback engine and records this text as the slot reason.
60
+ """
61
+
62
+
63
+ # ---------------------------------------------------------------------------------------------
64
+ # WAV inspection
65
+ # ---------------------------------------------------------------------------------------------
66
+
67
+ def inspect_wav(path: Path) -> dict[str, int] | None:
68
+ """Return ``{format, channels, rate, bits}`` for a RIFF/WAVE file, or ``None`` if it is not one.
69
+
70
+ ``format`` is the effective format tag: WAVE_FORMAT_EXTENSIBLE is resolved to its sub-format.
71
+ """
72
+ try:
73
+ with path.open("rb") as handle:
74
+ head = handle.read(12)
75
+ if len(head) < 12 or head[:4] != b"RIFF" or head[8:12] != b"WAVE":
76
+ return None
77
+ while True:
78
+ chunk = handle.read(8)
79
+ if len(chunk) < 8:
80
+ return None
81
+ tag, size = chunk[:4], struct.unpack("<I", chunk[4:])[0]
82
+ if tag == b"fmt ":
83
+ body = handle.read(min(size, 40))
84
+ if len(body) < 16:
85
+ return None
86
+ fmt, channels, rate, _byte_rate, _align, bits = struct.unpack("<HHIIHH", body[:16])
87
+ if fmt == 0xFFFE and len(body) >= 26:
88
+ fmt = struct.unpack("<H", body[24:26])[0]
89
+ return {"format": fmt, "channels": channels, "rate": rate, "bits": bits}
90
+ handle.seek(size + (size & 1), os.SEEK_CUR)
91
+ except OSError:
92
+ return None
93
+
94
+
95
+ def is_runtime_pcm(info: dict[str, int] | None) -> bool:
96
+ return bool(info) and info["format"] == 1 and info["bits"] == 16 and info["channels"] in (1, 2)
97
+
98
+
99
+ # ---------------------------------------------------------------------------------------------
100
+ # Source resolution
101
+ # ---------------------------------------------------------------------------------------------
102
+
103
+ def _is_safe_sample_name(name: str) -> bool:
104
+ return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.\- ]{0,200}", name))
105
+
106
+
107
+ def _ci_child(directory: Path, name: str) -> Path | None:
108
+ """Case-insensitive child lookup (BeamNG runs on Windows, so mods routinely mismatch case)."""
109
+ exact = directory / name
110
+ if os.path.lexists(exact):
111
+ return exact
112
+ lowered = name.lower()
113
+ try:
114
+ for entry in os.scandir(directory):
115
+ if entry.name.lower() == lowered:
116
+ return Path(entry.path)
117
+ except OSError:
118
+ return None
119
+ return None
120
+
121
+
122
+ def confine(source_root: Path, portable: str) -> Path | None:
123
+ """Resolve a mod-relative path inside ``source_root`` without following symlinks."""
124
+ if not isinstance(portable, str):
125
+ return None
126
+ portable = portable.replace("\\", "/").lstrip("/") if not re.match(r"^[A-Za-z]:", portable) else ""
127
+ if not portable:
128
+ return None
129
+ current = source_root
130
+ for piece in portable.split("/"):
131
+ if piece in ("", ".", ".."):
132
+ return None
133
+ found = _ci_child(current, piece)
134
+ if found is None or found.is_symlink():
135
+ return None
136
+ current = found
137
+ try:
138
+ current.resolve().relative_to(source_root.resolve())
139
+ except ValueError:
140
+ return None
141
+ return current if current.is_file() else None
142
+
143
+
144
+ def find_blend(source_root: Path, sample_name: str) -> Path | None:
145
+ """The ``<sampleName>.sfxBlend2D.json`` the mod ships, or ``None`` when it is a stock event."""
146
+ if not _is_safe_sample_name(sample_name):
147
+ return None
148
+ wanted = (sample_name + BLEND_SUFFIX).lower()
149
+ standard = confine(source_root, "/".join((*BLEND_DIRECTORY, sample_name + BLEND_SUFFIX)))
150
+ if standard is not None:
151
+ return standard
152
+ seen = 0
153
+ for directory, subdirectories, files in os.walk(source_root, followlinks=False):
154
+ subdirectories[:] = [d for d in subdirectories if not os.path.islink(os.path.join(directory, d))]
155
+ for name in files:
156
+ seen += 1
157
+ if seen > MAX_WALK_ENTRIES:
158
+ return None
159
+ if name.lower() == wanted and not os.path.islink(os.path.join(directory, name)):
160
+ return Path(directory, name)
161
+ return None
162
+
163
+
164
+ def parse_blend(path: Path) -> dict[str, Any]:
165
+ """Parse a sfxBlend2D file into ``{eventName, ladders: [[[portable, rpm], ...], ...]}``.
166
+
167
+ Rpm values come straight from the file. Each ladder is sorted by rpm; a ladder with fewer than
168
+ two distinct rungs, a non-numeric rpm or a duplicate rpm is refused rather than repaired.
169
+ """
170
+ if path.stat().st_size > MAX_BLEND_BYTES:
171
+ raise AudioUnavailable(f"sound blend {path.name} exceeds {MAX_BLEND_BYTES} bytes")
172
+ try:
173
+ body, _ = parse_jbeam_text(path.read_text(errors="ignore"))
174
+ except (ValueError, json.JSONDecodeError) as error:
175
+ raise AudioUnavailable(f"sound blend {path.name} is not valid JSON: {error}") from error
176
+ rows = body.get("samples")
177
+ if not isinstance(rows, list) or not rows:
178
+ raise AudioUnavailable(f"sound blend {path.name} has no `samples` ladders")
179
+ ladders: list[list[list[Any]]] = []
180
+ for row_index, row in enumerate(rows):
181
+ if not isinstance(row, list) or not row:
182
+ raise AudioUnavailable(f"sound blend {path.name} ladder {row_index} is empty")
183
+ ladder: list[list[Any]] = []
184
+ for rung in row:
185
+ if (not isinstance(rung, list) or len(rung) < 2 or not isinstance(rung[0], str) or not rung[0]
186
+ or isinstance(rung[1], bool) or not isinstance(rung[1], (int, float)) or rung[1] <= 0):
187
+ raise AudioUnavailable(
188
+ f"sound blend {path.name} ladder {row_index} has a rung that is not [file, rpm]: {rung!r}")
189
+ rpm = rung[1]
190
+ ladder.append([rung[0], int(rpm) if float(rpm).is_integer() else float(rpm)])
191
+ ladder.sort(key=lambda rung: rung[1])
192
+ rpms = [rung[1] for rung in ladder]
193
+ if len(set(rpms)) != len(rpms):
194
+ raise AudioUnavailable(f"sound blend {path.name} ladder {row_index} repeats an rpm value")
195
+ ladders.append(ladder)
196
+ event = body.get("eventName")
197
+ return {"eventName": event if isinstance(event, str) and event else "event:>Engine>default", "ladders": ladders}
198
+
199
+
200
+ # ---------------------------------------------------------------------------------------------
201
+ # Transcoding
202
+ # ---------------------------------------------------------------------------------------------
203
+
204
+ def locate_ffmpeg() -> str | None:
205
+ explicit = os.environ.get("HELIX_FFMPEG")
206
+ if explicit:
207
+ return explicit if os.access(explicit, os.X_OK) else None
208
+ return shutil.which("ffmpeg")
209
+
210
+
211
+ def transcode_to_pcm16(ffmpeg: str, source: Path, target: Path, rate: int | None) -> None:
212
+ command = [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(source), "-map", "0:a:0",
213
+ "-c:a", "pcm_s16le", "-fflags", "+bitexact", "-flags:a", "+bitexact"]
214
+ if rate:
215
+ command += ["-ar", str(rate)]
216
+ command.append(str(target))
217
+ try:
218
+ result = subprocess.run(command, capture_output=True, text=True, timeout=180)
219
+ except subprocess.TimeoutExpired as error:
220
+ raise AudioExtractionError(f"ffmpeg timed out transcoding {source.name}") from error
221
+ if result.returncode != 0 or not target.is_file() or target.stat().st_size == 0:
222
+ raise AudioExtractionError(f"ffmpeg could not transcode {source.name}: {result.stderr.strip()[:300]}")
223
+
224
+
225
+ def sha256_bytes(path: Path) -> tuple[str, int]:
226
+ digest = hashlib.sha256()
227
+ size = 0
228
+ with path.open("rb") as handle:
229
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
230
+ digest.update(chunk)
231
+ size += len(chunk)
232
+ return digest.hexdigest(), size
233
+
234
+
235
+ def asset_file_name(source_path: str, taken: dict[str, str], source_digest: str) -> str:
236
+ """A portable ``<stem>.wav`` name. Bare numbers (``1768.wav``) take their folder as a prefix and a
237
+ name already used by different bytes gets the content hash appended, so names stay unique."""
238
+ clean = lambda value: re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-") # noqa: E731
239
+ path = Path(source_path.replace("\\", "/"))
240
+ stem = clean(path.stem) or "sample"
241
+ if stem.isdigit() and clean(path.parent.name):
242
+ stem = f"{clean(path.parent.name)}_{stem}"
243
+ name = f"{stem}.wav"
244
+ if taken.get(name, source_digest) != source_digest:
245
+ name = f"{stem}-{source_digest[:8]}.wav"
246
+ return name
247
+
248
+
249
+ # ---------------------------------------------------------------------------------------------
250
+ # Public entry point
251
+ # ---------------------------------------------------------------------------------------------
252
+
253
+ def stock_reason(event: str) -> str:
254
+ return (f"source uses BeamNG stock FMOD event {event}; no recording ships in the mod "
255
+ "— platform fallback engine")
256
+
257
+
258
+ def resolve_layers(source_root: Path, engine_name: str | None, exhaust_name: str | None) -> dict[str, Any]:
259
+ """Classify the engine and exhaust layers as shipped-in-the-mod, stock-only, or absent."""
260
+ layers: dict[str, Any] = {}
261
+ for layer, name in (("engine", engine_name), ("exhaust", exhaust_name)):
262
+ clean = name.strip() if isinstance(name, str) else ""
263
+ if clean.lower() in DISABLED_SAMPLE_NAMES:
264
+ layers[layer] = {"state": "absent", "name": clean or None}
265
+ continue
266
+ blend = find_blend(source_root, clean)
267
+ layers[layer] = ({"state": "shipped", "name": clean, "blend": blend} if blend is not None
268
+ else {"state": "stock", "name": clean})
269
+ return layers
270
+
271
+
272
+ def extract_engine_bank(source_root: Path, layers: dict[str, Any], audio_dir: Path,
273
+ ffmpeg: str | None = None) -> dict[str, Any]:
274
+ """Write the WAVs for every shipped layer into ``audio_dir`` and return the bank description.
275
+
276
+ Returns ``{"blends": {NAME: {eventName, samples}}, "assets": [row, ...], "sources": [...],
277
+ "blendNames": {"engine": NAME | None, "exhaust": NAME | None}}``. Raises ``AudioExtractionError``
278
+ when the mod ships audio that cannot be turned into a faithful PCM WAV.
279
+ """
280
+ source_root = source_root.resolve()
281
+ shipped: dict[str, dict[str, Any]] = {}
282
+ for layer, info in layers.items():
283
+ if info["state"] != "shipped":
284
+ continue
285
+ parsed = parse_blend(info["blend"])
286
+ shipped[info["name"]] = {"parsed": parsed, "blend": info["blend"]}
287
+
288
+ # Gather every distinct recording the shipped blends name, in blend then ladder order.
289
+ resolved: dict[str, Path] = {}
290
+ for name, entry in shipped.items():
291
+ missing: list[str] = []
292
+ for ladder in entry["parsed"]["ladders"]:
293
+ for portable, _rpm in ladder:
294
+ if portable in resolved:
295
+ continue
296
+ path = confine(source_root, portable)
297
+ if path is None:
298
+ missing.append(portable)
299
+ else:
300
+ resolved[portable] = path
301
+ if missing:
302
+ raise AudioUnavailable(
303
+ f"sound blend {name} names {len(missing)} recording(s) the mod does not contain, "
304
+ f"for example {missing[0]}")
305
+ if len(resolved) > MAX_SAMPLE_FILES:
306
+ raise AudioUnavailable(f"the mod's engine blends name {len(resolved)} recordings (limit {MAX_SAMPLE_FILES})")
307
+ total = 0
308
+ inspected: dict[str, dict[str, int] | None] = {}
309
+ for portable, path in resolved.items():
310
+ size = path.stat().st_size
311
+ if size > MAX_SAMPLE_BYTES:
312
+ raise AudioUnavailable(f"recording {portable} is {size} bytes (limit {MAX_SAMPLE_BYTES})")
313
+ total += size
314
+ inspected[portable] = inspect_wav(path)
315
+ if total > MAX_TOTAL_BYTES:
316
+ raise AudioUnavailable(f"the mod's engine recordings total {total} bytes (limit {MAX_TOTAL_BYTES})")
317
+
318
+ # One bank sample rate: the most common rate among the recordings, else 44.1 kHz.
319
+ rates = Counter(info["rate"] for info in inspected.values() if info)
320
+ bank_rate = next((rate for rate, _ in sorted(rates.items(), key=lambda kv: (-kv[1], kv[0]))
321
+ if rate in BANK_RATES), DEFAULT_RATE)
322
+
323
+ def needs_transcode(portable: str) -> tuple[bool, int | None]:
324
+ info = inspected[portable]
325
+ if info is None or not is_runtime_pcm(info):
326
+ return True, bank_rate if (info is None or info["rate"] != bank_rate) else None
327
+ return (info["rate"] != bank_rate), (bank_rate if info["rate"] != bank_rate else None)
328
+
329
+ ffmpeg_needed = [p for p in resolved if needs_transcode(p)[0]]
330
+ if ffmpeg_needed and ffmpeg is None:
331
+ raise AudioExtractionError(
332
+ f"{len(ffmpeg_needed)} engine recording(s) (for example {ffmpeg_needed[0]}) are not 16-bit PCM WAV "
333
+ f"at {BANK_RATES[0]}/{BANK_RATES[1]} Hz and need ffmpeg to convert; install ffmpeg (or set "
334
+ "HELIX_FFMPEG) and run the import again. The importer never ships OGG/FLAC or fabricates samples.")
335
+
336
+ audio_dir.mkdir(parents=True, exist_ok=True)
337
+ names_by_source: dict[str, str] = {}
338
+ digest_by_name: dict[str, str] = {}
339
+ rows: dict[str, dict[str, Any]] = {}
340
+ sources: list[dict[str, Any]] = []
341
+ with tempfile.TemporaryDirectory(prefix="beamng-audio-") as scratch:
342
+ for portable, path in resolved.items():
343
+ source_digest, _ = sha256_bytes(path)
344
+ name = asset_file_name(portable, digest_by_name, source_digest)
345
+ digest_by_name[name] = source_digest
346
+ names_by_source[portable] = name
347
+ target = audio_dir / name
348
+ transcode, rate = needs_transcode(portable)
349
+ if transcode:
350
+ staged = Path(scratch) / name
351
+ transcode_to_pcm16(ffmpeg, path, staged, rate) # type: ignore[arg-type]
352
+ shutil.move(str(staged), target)
353
+ else:
354
+ shutil.copyfile(path, target)
355
+ checksum, size = sha256_bytes(target)
356
+ rows[name] = {"name": name, "file": name, "path": f"audio/{name}", "role": ASSET_ROLE,
357
+ "mediaType": "audio/wav", "bytes": size, "checksumSha256": checksum,
358
+ "cid": f"cid:sha256:{checksum}"}
359
+ sources.append({"asset": name, "sourcePath": portable, "sourceSha256": source_digest,
360
+ "transcoded": transcode})
361
+
362
+ blends: dict[str, dict[str, Any]] = {}
363
+ for name, entry in shipped.items():
364
+ blends[name] = {"eventName": entry["parsed"]["eventName"],
365
+ "samples": [[[names_by_source[portable], rpm] for portable, rpm in ladder]
366
+ for ladder in entry["parsed"]["ladders"]]}
367
+ return {"blends": blends, "assets": [rows[name] for name in sorted(rows)], "sources": sources,
368
+ "sampleRate": bank_rate,
369
+ "blendNames": {layer: (info["name"] if info["state"] == "shipped" else None)
370
+ for layer, info in layers.items()}}
@@ -25,6 +25,8 @@ from beamng_jbeam import (JBeamDocumentCache, MAX_SELECTED_PART_INDEX_BYTES,
25
25
  resolve_part_closure, selected_part_axle)
26
26
  from beamng_expression import ExpressionResolution, resolve_beamng_expression
27
27
  from beamng_abs import lower_selected_abs
28
+ from beamng_audio import (AudioExtractionError, AudioUnavailable, extract_engine_bank, locate_ffmpeg,
29
+ resolve_layers, stock_reason)
28
30
 
29
31
  SCHEMA = "helix.beamng-functional-lowering/3"
30
32
  SOURCE_SCHEMA = "helix.beamng-vehicle-source/1"
@@ -1525,6 +1527,8 @@ def main() -> None:
1525
1527
  parser.add_argument("--part-index")
1526
1528
  parser.add_argument("--abs-controller-receipt")
1527
1529
  parser.add_argument("--common-root", action="append", default=[])
1530
+ parser.add_argument("--audio-dir", help="where the mod's own engine recordings are written as PCM WAV "
1531
+ "(default: <output dir>/audio)")
1528
1532
  args = parser.parse_args()
1529
1533
 
1530
1534
  source_root = Path(args.source_root).resolve()
@@ -2045,12 +2049,87 @@ def main() -> None:
2045
2049
  candidate_sample = next((x for x in indexed_values(value_index, "sampleName")
2046
2050
  if isinstance(x, str) and "exhaust" in x.lower()), None)
2047
2051
  if candidate_sample: exhaust_sample = candidate_sample
2048
- cylinders = int(sound.get("fundamentalFrequencyCylinderCount", 4) or 4)
2052
+ declared_cylinders = sound.get("fundamentalFrequencyCylinderCount")
2053
+ cylinders = int(declared_cylinders) if isinstance(declared_cylinders, (int, float)) \
2054
+ and not isinstance(declared_cylinders, bool) and declared_cylinders >= 1 else 4
2055
+ audio_layers = resolve_layers(source_root, engine_sample, exhaust_sample)
2056
+ audio_bank: dict[str, Any] | None = None
2057
+ audio_unavailable: str | None = None
2058
+ if any(layer["state"] == "shipped" for layer in audio_layers.values()):
2059
+ try:
2060
+ audio_bank = extract_engine_bank(source_root, audio_layers,
2061
+ Path(args.audio_dir) if args.audio_dir else Path(args.output).parent / "audio",
2062
+ locate_ffmpeg())
2063
+ except AudioUnavailable as exc:
2064
+ audio_unavailable = str(exc)
2065
+ engine_sample = engine_sample if isinstance(engine_sample, str) and audio_layers["engine"]["state"] != "absent" else None
2066
+ exhaust_sample = exhaust_sample if isinstance(exhaust_sample, str) and audio_layers["exhaust"]["state"] != "absent" else None
2067
+
2068
+ def layer_reason(layer: str) -> str:
2069
+ state, name = audio_layers[layer]["state"], audio_layers[layer]["name"]
2070
+ if audio_unavailable and state == "shipped":
2071
+ return f"source blend {name} is unusable ({audio_unavailable}); platform fallback engine"
2072
+ if state == "stock":
2073
+ return stock_reason(name)
2074
+ return f"source declares no {layer} sound sample (sampleName {name!r}); platform fallback engine"
2075
+
2076
+ bank_blends = audio_bank["blends"] if audio_bank else {}
2077
+ engine_blend = audio_bank["blendNames"]["engine"] if audio_bank else None
2078
+ exhaust_blend = audio_bank["blendNames"]["exhaust"] if audio_bank else None
2079
+ exhaust_shares_engine = False
2080
+ if audio_bank and engine_blend and audio_layers["exhaust"]["state"] == "absent":
2081
+ # BeamNG mods such as the 993 disable the exhaust layer (sampleName "0") because the single
2082
+ # recording already carries the exhaust; the same set serves both HELIX ladders.
2083
+ exhaust_blend, exhaust_shares_engine = engine_blend, True
2084
+
2085
+ def ladder_note(blend_name: str) -> str:
2086
+ ladders = bank_blends[blend_name]["samples"]
2087
+ rungs = [rung[1] for rung in ladders[0]]
2088
+ return (f"{len(ladders[0])} rungs {min(rungs):g}-{max(rungs):g} rpm from the mod's own recording "
2089
+ f"(sfxBlend2D {blend_name}), {len(ladders)} load row(s).")
2090
+
2049
2091
  audio_slots = []
2050
2092
  for slot_name in AUDIO_SLOTS:
2051
- sample = engine_sample if slot_name == "engineLadder" else exhaust_sample if slot_name == "exhaustLadder" else None
2052
- audio_slots.append({"slot": slot_name, "buffers": [sample] if sample else [], "consumed": bool(sample),
2053
- **({} if sample else {"reason": "No selected source event was mapped to this HELIX slot."})})
2093
+ row: dict[str, Any] = {"slot": slot_name, "buffers": [], "consumed": False,
2094
+ "reason": "No selected source event was mapped to this HELIX slot."}
2095
+ if slot_name in ("engineLadder", "exhaustLadder"):
2096
+ layer = "engine" if slot_name == "engineLadder" else "exhaust"
2097
+ blend_name = engine_blend if layer == "engine" else exhaust_blend
2098
+ sample = engine_sample if layer == "engine" else exhaust_sample
2099
+ if blend_name:
2100
+ note = ladder_note(blend_name)
2101
+ if layer == "exhaust" and exhaust_shares_engine:
2102
+ declared = audio_layers["exhaust"]["name"]
2103
+ note = (f"The source declares no separate exhaust sample (sampleName {declared!r}); the engine "
2104
+ "recording is one combined set used on both ladders. " + note) if declared is not None else \
2105
+ ("The source declares no separate exhaust sample; the engine recording is one combined "
2106
+ "set used on both ladders. " + note)
2107
+ row = {"slot": slot_name, "buffers": [f"blend:{blend_name}"], "consumed": True, "note": note}
2108
+ else:
2109
+ row = {"slot": slot_name, "buffers": [sample] if sample and not audio_bank else [],
2110
+ "consumed": False, "reason": layer_reason(layer)}
2111
+ audio_slots.append(row)
2112
+
2113
+ if audio_bank:
2114
+ bank_source = source_label(Path(audio_layers["engine"]["blend"] if audio_layers["engine"]["state"] == "shipped"
2115
+ else audio_layers["exhaust"]["blend"]))
2116
+ for layer, info in audio_layers.items():
2117
+ if info["state"] == "shipped":
2118
+ prov(f"audio.bank.blends.{info['name']}", source_label(info["blend"]), "source-literal",
2119
+ "rpm ladders copied from the mod's own sfxBlend2D file; rpm values are the source's.")
2120
+ for origin in audio_bank["sources"]:
2121
+ prov(f"audio.bank.assets.{origin['asset']}", source_label(source_root / origin["sourcePath"]),
2122
+ "derived" if origin["transcoded"] else "source-literal",
2123
+ "Recording shipped in the mod; transcoded to 16-bit PCM WAV." if origin["transcoded"]
2124
+ else "Recording shipped in the mod, copied byte for byte.")
2125
+ if not isinstance(declared_cylinders, (int, float)):
2126
+ prov("audio.sampleCylinderCount", bank_source, "defaulted",
2127
+ "BeamNG does not declare how many cylinders the recorded engine had; defaulted to 4. "
2128
+ "Set audio.sampleCylinderCount to the recorded engine's cylinder count.")
2129
+ elif audio_unavailable or engine_sample or exhaust_sample:
2130
+ prov("audio.bank", "selected soundConfig sampleName", "unsupported",
2131
+ audio_unavailable or "Source uses BeamNG stock FMOD events; no recording ships in the mod, so the "
2132
+ "platform fallback engine plays.")
2054
2133
 
2055
2134
  engine_spec = {"torqueCurve": {"points": torque_curve}, "idleRpm": idle, "limiterRpm": limiter,
2056
2135
  "revLimiterType": "soft", "inertia": inertia, "friction": friction,
@@ -2144,7 +2223,9 @@ def main() -> None:
2144
2223
  else sourced(fallback, unit, note="Provisional functional acceptance target; no BeamNG literal supplied.")) | {"tolerance": .35}
2145
2224
  for name, (raw, fallback, unit) in target_specs.items()},
2146
2225
  "audio": {"bankId": package_id, "sampleCylinderCount": cylinders, "strokes": 4,
2147
- **({"engineBlend": engine_sample} if engine_sample else {}), **({"exhaustBlend": exhaust_sample} if exhaust_sample else {}),
2226
+ **({"engineBlend": engine_blend} if engine_blend else {"engineBlend": engine_sample} if engine_sample and not audio_bank else {}),
2227
+ **({"exhaustBlend": exhaust_blend} if exhaust_blend else {"exhaustBlend": exhaust_sample} if exhaust_sample and not audio_bank else {}),
2228
+ **({"bank": {"blends": audio_bank["blends"], "assets": audio_bank["assets"]}} if audio_bank else {}),
2148
2229
  "slots": audio_slots, "engineGainDb": float(sound.get("mainGain", 0) or 0), "exhaustGainDb": float(exhaust.get("mainGain", 0) or 0),
2149
2230
  "minLoadMix": float(sound.get("minLoadMix", 0) or 0), "maxLoadMix": float(sound.get("maxLoadMix", 1) or 1), "muffling": float(sound.get("intakeMuffling", 0) or 0)},
2150
2231
  "lights": {"lamps": lamps, "indicatorHz": 1.5, "decelBrakeLightThreshold": 4},
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "helix.bridge-plugin/1",
3
3
  "id": "hypersonic.beamng-vehicle",
4
4
  "name": "BeamNG Vehicle Importer",
5
- "version": "0.6.2",
5
+ "version": "0.7.0",
6
6
  "trust": "local-untrusted",
7
7
  "description": "Assess, convert, texture, lower, and acceptance-test a BeamNG vehicle plus its exact configuration before HELIX packaging.",
8
8
  "permissions": [