@maccesar/titools 5.1.1 → 5.2.0

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.
Files changed (69) hide show
  1. package/README.md +44 -12
  2. package/agents/ti-pro.md +4 -0
  3. package/commands/ti-audit.md +4 -0
  4. package/lib/config.js +2 -0
  5. package/package.json +1 -1
  6. package/skills/ti-admob-unified/SKILL.md +44 -0
  7. package/skills/ti-admob-unified/agents/openai.yaml +4 -0
  8. package/skills/ti-admob-unified/assets/example/app.js +1003 -0
  9. package/skills/ti-admob-unified/assets/example/lib/ads.js +618 -0
  10. package/skills/ti-admob-unified/assets/example/library-usage.js +104 -0
  11. package/skills/ti-admob-unified/references/api-reference.md +592 -0
  12. package/skills/ti-admob-unified/references/banners.md +435 -0
  13. package/skills/ti-admob-unified/references/contributing.md +228 -0
  14. package/skills/ti-admob-unified/references/dependencies.md +85 -0
  15. package/skills/ti-admob-unified/references/fullscreen.md +223 -0
  16. package/skills/ti-admob-unified/references/game-monetization.md +17 -0
  17. package/skills/ti-admob-unified/references/getting-started.md +275 -0
  18. package/skills/ti-admob-unified/references/index.md +30 -0
  19. package/skills/ti-admob-unified/references/integration.md +85 -0
  20. package/skills/ti-admob-unified/references/library.md +189 -0
  21. package/skills/ti-admob-unified/references/mediation.md +132 -0
  22. package/skills/ti-admob-unified/references/migration.md +255 -0
  23. package/skills/ti-admob-unified/references/module-changelog.md +54 -0
  24. package/skills/ti-admob-unified/references/module-license.txt +21 -0
  25. package/skills/ti-admob-unified/references/privacy.md +254 -0
  26. package/skills/ti-admob-unified/references/roadmap.md +146 -0
  27. package/skills/ti-admob-unified/references/source-notes.md +33 -0
  28. package/skills/ti-admob-unified/references/source-snapshot.json +440 -0
  29. package/skills/ti-admob-unified/references/third-party-notices.md +22 -0
  30. package/skills/ti-game/SKILL.md +10 -1
  31. package/skills/ti-game/references/api.md +10 -2
  32. package/skills/ti-game/references/debugging-performance.md +6 -0
  33. package/skills/ti-game/references/examples.md +11 -1
  34. package/skills/ti-game/references/patterns.md +24 -2
  35. package/skills/ti-game/references/physics.md +502 -0
  36. package/skills/ti-game/references/project-setup.md +23 -1
  37. package/skills/ti-game/references/recipes-action.md +46 -14
  38. package/skills/ti-game/references/recipes-adventure-effects.md +12 -1
  39. package/skills/ti-game/references/roadmap.md +2 -2
  40. package/skills/ti-game/references/tilemaps.md +2 -1
  41. package/skills/ti-game-assets/SKILL.md +53 -0
  42. package/skills/ti-game-assets/agents/openai.yaml +4 -0
  43. package/skills/ti-game-assets/references/alpha.md +26 -0
  44. package/skills/ti-game-assets/references/art-direction.md +37 -0
  45. package/skills/ti-game-assets/references/integration.md +49 -0
  46. package/skills/ti-game-assets/references/manifest.example.json +429 -0
  47. package/skills/ti-game-assets/references/manifest.md +39 -0
  48. package/skills/ti-game-assets/references/offline-audio.md +33 -0
  49. package/skills/ti-game-assets/references/prompts.md +33 -0
  50. package/skills/ti-game-assets/references/repeating-textures.md +17 -0
  51. package/skills/ti-game-assets/references/responsible-ads.md +31 -0
  52. package/skills/ti-game-assets/references/sound-assets.md +42 -0
  53. package/skills/ti-game-assets/references/vector-sources.md +21 -0
  54. package/skills/ti-game-assets/scripts/alpha_tools.py +160 -0
  55. package/skills/ti-game-assets/scripts/requirements.txt +1 -0
  56. package/skills/ti-game-assets/scripts/validate_manifest.py +267 -0
  57. package/skills/ti-synthengine/SKILL.md +37 -13
  58. package/skills/ti-synthengine/references/api.md +397 -31
  59. package/skills/ti-synthengine/references/audio-engine.md +235 -14
  60. package/skills/ti-synthengine/references/examples.md +396 -17
  61. package/skills/ti-synthengine/references/guide.md +331 -12
  62. package/skills/ti-synthengine/references/recipes.md +212 -22
  63. package/skills/ti-synthengine/references/recommendations.md +53 -16
  64. package/skills/ti-synthengine/references/sound-design.md +359 -15
  65. package/skills/ti-synthengine/references/source-notes.md +121 -0
  66. package/skills/ti-synthengine/references/source-snapshot.json +105 -0
  67. package/skills/ti-synthengine/references/stats-performance.json +2992 -0
  68. package/skills/ti-synthengine/references/stats-performance.md +38 -0
  69. package/skills/ti-synthengine/references/troubleshooting.md +61 -4
@@ -0,0 +1,21 @@
1
+ # Editable vector and procedural sources
2
+
3
+ Use an existing SVG/procedural workflow when the project uses it or the user requests editable art generated with code. Integrated `image_gen` remains the default for raster drawing creation/edits; it is not a requirement to replace authored vectors. Rasterizing an SVG is an export step, not image-model generation.
4
+
5
+ ## Source of truth
6
+
7
+ Keep the editable SVG, generator and parameters as canonical sources; retain individual full-canvas PNG renders as reproducible derivatives. Avoid making a generated PNG the only editable master. Preserve original/legacy source when it has a documented purpose. Name symbols and groups by meaningful parts (hull, cockpit, lights, propulsion, appendages), with explicit `viewBox`, proportions and pivots. Parameterize palette, poses and damage overlays without shifting silhouette or anchor accidentally.
8
+
9
+ Record deterministic seeds for procedural distributions. Use a maintained SVG rasterizer already in the project, such as `rsvg-convert`/librsvg, and record its version, font dependencies, antialiasing and target dimensions. Rasterizer changes can change bytes: regenerate and review the affected set rather than merely updating hashes. Avoid hidden system-font or external-image dependencies in gameplay graphics.
10
+
11
+ Reuse a build that emits PNG sheets, runtime catalogs/frame maps, source/generator/output hashes, contact sheets and animation/parallax previews together. The runtime consumes generated PNG/catalog data, not the offline generator. Verify missing/empty cells, frame bounds, dimensions, anchor consistency, stale output hashes and repeat seams. Run it twice in a disposable output directory to check determinism in a fixed environment. Do not infer human authorship from a PNG compression filter or overwrite policy: preserve authored assets through explicit provenance.
12
+
13
+ Render at the declared logical proportions and preview at actual game sizes, in grayscale and relevant color-vision simulations. Distinguish roles by silhouette/symbol as well as color. Preserve warning readability under reduced-flash settings; do not silently change mechanics, flashes or accessibility preferences while exporting.
14
+
15
+ ## Manifest extension
16
+
17
+ Keep `source` pointing at the PNG render inspected by the bundled validator; add `vector_source` pointing at its existing SVG master. The validator checks an SVG root and finite positive `viewBox` extents, not vector aesthetics or generation dependencies. Record generator, parameters and render settings in provenance. Optional top-level `build.files` maps source/generator/output paths to SHA-256 hashes; validation detects modified declared files. Include *all* relevant dependencies, not just outputs. Hash agreement does not prove a build was run or that a rasterizer is correct.
18
+
19
+ ## Evidence from Guardian
20
+
21
+ `docs/art/source/guardian-parts.svg` holds named symbols/editable groups. `docs/scripts/generate-art.js` creates PNGs, runtime catalog and review sheets. `docs/scripts/verify-art.js` checks hashes, cells, animation frames and background edges. `docs/art/README.md` documents the renderer and why its high-resolution antialiased art uses smoothing. These are reusable design choices, not a requirement to copy its cell sizes, frame counts or art.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect alpha; explicitly remove reviewed exterior residue into new files."""
3
+ import argparse
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+
8
+ from PIL import Image, ImageDraw
9
+
10
+ THRESHOLDS = (1, 4, 8, 16, 32)
11
+
12
+
13
+ def bbox(alpha, threshold):
14
+ return alpha.point(lambda value: 255 if value >= threshold else 0).getbbox()
15
+
16
+
17
+ def analyze(image):
18
+ alpha = image.getchannel('A')
19
+ histogram = alpha.histogram()
20
+ return {
21
+ 'dimensions': list(image.size),
22
+ 'alpha_range': list(alpha.getextrema()),
23
+ 'translucent_pixels': sum(histogram[1:255]),
24
+ 'visible_pixels': sum(histogram[1:]),
25
+ 'bounding_boxes': {str(t): bbox(alpha, t) for t in THRESHOLDS},
26
+ }
27
+
28
+
29
+ def rectangle(values, size):
30
+ x0, y0, x1, y1 = values
31
+ if not (0 <= x0 < x1 <= size[0] and 0 <= y0 < y1 <= size[1]):
32
+ raise ValueError('rectangle must be nonempty and inside the source canvas')
33
+ return tuple(values)
34
+
35
+
36
+ def clean(image, protected):
37
+ alpha = image.getchannel('A')
38
+ retained = Image.new('L', image.size, 0)
39
+ retained.paste(alpha.crop(protected), protected[:2])
40
+ result = image.copy()
41
+ result.putalpha(retained)
42
+ return result
43
+
44
+
45
+ def preview(before, after=None):
46
+ images = [before] if after is None else [before, after]
47
+ cell = 280
48
+ output = Image.new('RGB', (cell * len(images), (cell + 24) * 3), 'white')
49
+ draw = ImageDraw.Draw(output)
50
+ for column, source in enumerate(images):
51
+ scaled = source.copy()
52
+ scaled.thumbnail((cell, cell), Image.Resampling.NEAREST)
53
+ for row, background in enumerate(('checker', '#eeeeee', '#181818')):
54
+ x, y = column * cell, row * (cell + 24) + 24
55
+ panel = Image.new('RGBA', (cell, cell), background if row else '#cccccc')
56
+ if row == 0:
57
+ painter = ImageDraw.Draw(panel)
58
+ for cy in range(0, cell, 16):
59
+ for cx in range(0, cell, 16):
60
+ if (cx // 16 + cy // 16) % 2:
61
+ painter.rectangle((cx, cy, cx + 15, cy + 15), fill='#999999')
62
+ panel.alpha_composite(scaled)
63
+ output.paste(panel.convert('RGB'), (x, y))
64
+ draw.text((x + 4, y - 20), ('original' if column == 0 else 'candidate') + ' / ' + background, fill='black')
65
+ for threshold, color in zip(THRESHOLDS, ('red', 'orange', 'lime', 'cyan', 'magenta')):
66
+ box = bbox(source.getchannel('A'), threshold)
67
+ if box:
68
+ sx, sy = scaled.width / source.width, scaled.height / source.height
69
+ draw.rectangle((x + box[0] * sx, y + box[1] * sy,
70
+ x + (box[2] - 1) * sx, y + (box[3] - 1) * sy), outline=color)
71
+ return output
72
+
73
+
74
+ def main():
75
+ parser = argparse.ArgumentParser(description=__doc__)
76
+ parser.add_argument('source', type=Path)
77
+ parser.add_argument('--profile', choices=('pixel-art', 'hard-surface', 'soft-fx'), default='hard-surface')
78
+ parser.add_argument('--logical-size', nargs=2, type=int, metavar=('W', 'H'))
79
+ parser.add_argument('--threshold', type=int, default=8)
80
+ parser.add_argument('--protect', nargs=4, type=int, metavar=('X0', 'Y0', 'X1', 'Y1'))
81
+ parser.add_argument('--auto-protect', action='store_true')
82
+ parser.add_argument('--reason', help='reviewed residue and justification for the protected region')
83
+ parser.add_argument('--clean', type=Path, help='new PNG output; omit for analysis only')
84
+ crop = parser.add_mutually_exclusive_group()
85
+ crop.add_argument('--crop', action='store_true')
86
+ crop.add_argument('--crop-box', nargs=4, type=int, metavar=('X0', 'Y0', 'X1', 'Y1'))
87
+ parser.add_argument('--report', type=Path)
88
+ parser.add_argument('--preview', type=Path)
89
+ args = parser.parse_args()
90
+ try:
91
+ if not 1 <= args.threshold <= 255:
92
+ raise ValueError('threshold must be 1..255')
93
+ if args.logical_size and min(args.logical_size) <= 0:
94
+ raise ValueError('logical size must be positive')
95
+ if args.protect and args.auto_protect:
96
+ raise ValueError('choose explicit or automatic protection, not both')
97
+ if not args.clean and (args.crop or args.crop_box or args.protect or args.auto_protect):
98
+ raise ValueError('protection and crop flags require --clean')
99
+ outputs = [p.resolve() for p in (args.clean, args.report, args.preview) if p]
100
+ if len(outputs) != len(set(outputs)) or args.source.resolve() in outputs:
101
+ raise ValueError('outputs must be distinct from each other and the source')
102
+ if any(p.exists() for p in outputs):
103
+ raise ValueError('refusing to overwrite an existing output')
104
+ with Image.open(args.source) as opened:
105
+ source = opened.convert('RGBA')
106
+ report = {'source': str(args.source), 'profile': args.profile, 'analysis': analyze(source)}
107
+ candidate = None
108
+ if args.clean:
109
+ if not args.reason or not args.reason.strip():
110
+ raise ValueError('cleaning requires --reason documenting the review')
111
+ if args.profile == 'soft-fx' and (not args.protect or args.auto_protect):
112
+ raise ValueError('soft-fx requires an explicit protected region')
113
+ if args.profile == 'pixel-art' and not args.logical_size:
114
+ raise ValueError('pixel-art cleaning requires --logical-size')
115
+ padding = [math.ceil(source.size[i] / args.logical_size[i]) for i in (0, 1)] if args.logical_size else [1, 1]
116
+ if args.protect:
117
+ protected = rectangle(args.protect, source.size)
118
+ elif args.auto_protect:
119
+ detected = bbox(source.getchannel('A'), args.threshold)
120
+ if not detected:
121
+ raise ValueError('no content at threshold; preserve source and inspect lower thresholds')
122
+ protected = (max(0, detected[0] - padding[0]), max(0, detected[1] - padding[1]),
123
+ min(source.width, detected[2] + padding[0]), min(source.height, detected[3] + padding[1]))
124
+ else:
125
+ raise ValueError('cleaning requires --protect or reviewed --auto-protect')
126
+ candidate = clean(source, protected)
127
+ report['cleanup'] = {
128
+ 'protected_region': protected, 'threshold': args.threshold,
129
+ 'logical_size': args.logical_size, 'logical_pixel_padding': padding,
130
+ 'reason': args.reason, 'region_mode': 'explicit' if args.protect else 'automatic-trial',
131
+ 'removed_pixels': report['analysis']['visible_pixels'] - analyze(candidate)['visible_pixels'],
132
+ 'analysis': analyze(candidate),
133
+ }
134
+ trim = args.crop_box or (bbox(candidate.getchannel('A'), 1) if args.crop else None)
135
+ if (args.crop or args.crop_box) and not bbox(candidate.getchannel('A'), 1):
136
+ raise ValueError('cannot crop empty content')
137
+ if trim:
138
+ trim = rectangle(trim, source.size)
139
+ visible = bbox(candidate.getchannel('A'), 1)
140
+ if visible and not (trim[0] <= visible[0] and trim[1] <= visible[1] and trim[2] >= visible[2] and trim[3] >= visible[3]):
141
+ raise ValueError('crop box would discard visible content')
142
+ report['export'] = {'path': str(args.clean), 'offset': list(trim[:2]) if trim else [0, 0],
143
+ 'source_dimensions': list(source.size),
144
+ 'dimensions': [trim[2] - trim[0], trim[3] - trim[1]] if trim else list(source.size)}
145
+ # Build comparison before writing any output. It uses common source canvases.
146
+ comparison = preview(source, candidate) if args.preview else None
147
+ if args.clean:
148
+ (candidate.crop(trim) if trim else candidate).save(args.clean, format='PNG')
149
+ if args.preview:
150
+ comparison.save(args.preview, format='PNG')
151
+ encoded = json.dumps(report, indent=2)
152
+ if args.report:
153
+ args.report.write_text(encoded + '\n', encoding='utf-8')
154
+ print(encoded)
155
+ except (ValueError, OSError) as error:
156
+ parser.error(str(error))
157
+
158
+
159
+ if __name__ == '__main__':
160
+ main()
@@ -0,0 +1 @@
1
+ Pillow>=10
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Validate per-set game art and sound contracts, files, frames and parallax."""
3
+ import argparse
4
+ from collections import Counter, defaultdict
5
+ import json
6
+ import hashlib
7
+ import re
8
+ import wave
9
+ import xml.etree.ElementTree as ET
10
+ import math
11
+ from pathlib import Path
12
+
13
+ from PIL import Image
14
+
15
+ FAMILIES = {'players', 'enemies', 'allies', 'obstacles', 'ammunition', 'rewards', 'ambient', 'backgrounds', 'technical'}
16
+
17
+
18
+ def numbers(value, length, positive=False):
19
+ return (isinstance(value, list) and len(value) == length
20
+ and all(type(n) in (int, float) and math.isfinite(n) and (not positive or n > 0) for n in value))
21
+
22
+
23
+ def dimensions(value):
24
+ return numbers(value, 2, True) and all(type(n) is int for n in value)
25
+
26
+
27
+ def validate(data, root):
28
+ errors = []
29
+ counts = []
30
+
31
+ def require(condition, message):
32
+ if not condition:
33
+ errors.append(message)
34
+
35
+ def source_path(value, label):
36
+ if not isinstance(value, str) or not value:
37
+ errors.append(label + ': missing source')
38
+ return None
39
+ path = (root / value).resolve()
40
+ if not path.is_file():
41
+ errors.append(label + ': source not found: ' + value)
42
+ return None
43
+ return path
44
+
45
+ build = data.get('build')
46
+ if build is not None:
47
+ files = build.get('files') if isinstance(build, dict) else None
48
+ require(isinstance(files, dict) and bool(files), 'build.files must be a nonempty path-to-SHA256 object')
49
+ for filename, digest in files.items() if isinstance(files, dict) else []:
50
+ valid_hash = isinstance(digest, str) and re.fullmatch(r'[0-9a-f]{64}', digest) is not None
51
+ require(valid_hash, 'build: invalid SHA256 for ' + filename)
52
+ path = source_path(filename, 'build')
53
+ if path and valid_hash:
54
+ require(hashlib.sha256(path.read_bytes()).hexdigest() == digest, 'build: stale source/output hash: ' + filename)
55
+
56
+ require(data.get('schema_version') == 1, 'schema_version must be 1')
57
+ contracts = data.get('contracts', {})
58
+ sets = data.get('sets', [])
59
+ require(isinstance(contracts, dict) and bool(contracts), 'contracts must be a nonempty object')
60
+ require(isinstance(sets, list) and bool(sets), 'sets must be a nonempty list')
61
+ if not isinstance(contracts, dict) or not isinstance(sets, list):
62
+ return errors, counts
63
+ seen_sets = set()
64
+ for group in sets:
65
+ if not isinstance(group, dict):
66
+ errors.append('set must be an object')
67
+ continue
68
+ name = group.get('id')
69
+ require(isinstance(name, str) and bool(name) and name not in seen_sets, 'set ids must be unique nonempty strings')
70
+ if not isinstance(name, str):
71
+ continue
72
+ seen_sets.add(name)
73
+ label = name + ': '
74
+ contract = contracts.get(group.get('contract'))
75
+ if not isinstance(contract, dict):
76
+ errors.append(label + 'unknown contract')
77
+ continue
78
+ expected = Counter()
79
+ definitions = contract.get('resources', [])
80
+ expected_sounds = Counter()
81
+ require(isinstance(definitions, list), label + 'contract resources must be a list')
82
+ for definition in definitions if isinstance(definitions, list) else []:
83
+ resource, family, variant, state = (definition.get(k) for k in ('resource', 'family', 'variant', 'state'))
84
+ frames = definition.get('frames')
85
+ if not all(isinstance(v, str) and v for v in (resource, family, variant, state)) or type(frames) is not int or frames < 1:
86
+ errors.append(label + 'invalid resource definition')
87
+ continue
88
+ require(family in FAMILIES, label + 'unknown resource family ' + family)
89
+ for frame in range(frames):
90
+ expected[(resource, family, variant, state, frame)] += 1
91
+ require(all(n == 1 for n in expected.values()), label + 'duplicate contract resource definition')
92
+ sound_defs = contract.get('sounds', [])
93
+ require(isinstance(sound_defs, list), label + 'contract sounds must be a list')
94
+ for definition in sound_defs if isinstance(sound_defs, list) else []:
95
+ sound, variants = definition.get('sound'), definition.get('variants')
96
+ if not isinstance(sound, str) or not sound or not isinstance(variants, list) or not variants or not all(isinstance(v, str) and v for v in variants):
97
+ errors.append(label + 'invalid sound definition')
98
+ continue
99
+ expected_sounds.update((sound, variant) for variant in variants)
100
+ require(all(n == 1 for n in expected_sounds.values()), label + 'duplicate sound contract variant')
101
+ require(bool(expected or expected_sounds), label + 'contract must require visual or sound assets')
102
+ assets, sounds = group.get('assets', []), group.get('sounds', [])
103
+ require(isinstance(assets, list) and isinstance(sounds, list), label + 'assets and sounds must be lists')
104
+ if not isinstance(assets, list) or not isinstance(sounds, list):
105
+ continue
106
+ actual = Counter()
107
+ ids = set()
108
+ asset_by_id = {}
109
+ alignment = defaultdict(list)
110
+ sequences = defaultdict(list)
111
+ for asset in assets:
112
+ identity = asset.get('id')
113
+ require(isinstance(identity, str) and bool(identity) and identity not in ids, label + 'asset ids must be unique strings')
114
+ if not isinstance(identity, str):
115
+ continue
116
+ ids.add(identity)
117
+ asset_by_id[identity] = asset
118
+ fields = [asset.get(k) for k in ('resource', 'family', 'variant', 'state')]
119
+ frame = asset.get('frame')
120
+ if not all(isinstance(v, str) and v for v in fields) or type(frame) is not int or frame < 0:
121
+ errors.append(label + identity + ': invalid identity/frame')
122
+ continue
123
+ actual[tuple(fields) + (frame,)] += 1
124
+ sequences[tuple(fields)].append(frame)
125
+ size, canvas, pivot = (asset.get(k) for k in ('dimensions', 'canvas', 'pivot'))
126
+ require(dimensions(size), label + identity + ': dimensions must be positive integers')
127
+ require(dimensions(canvas), label + identity + ': canvas must be positive integers')
128
+ require(numbers(pivot, 2), label + identity + ': pivot must be finite coordinates')
129
+ require(size == canvas, label + identity + ': master must retain its common canvas')
130
+ if dimensions(canvas) and numbers(pivot, 2):
131
+ require(all(0 <= pivot[i] <= canvas[i] for i in (0, 1)), label + identity + ': pivot outside canvas')
132
+ alignment[tuple(fields[:3])].append((canvas, pivot, asset.get('logical_size')))
133
+ require(type(asset.get('transparency')) is bool, label + identity + ': transparency must be Boolean')
134
+ style = asset.get('style')
135
+ require(style in ('pixel-art', 'hard-surface', 'soft-fx', 'hd'), label + identity + ': unknown style')
136
+ if style == 'pixel-art':
137
+ require(dimensions(asset.get('logical_size')), label + identity + ': pixel-art requires logical_size')
138
+ require(isinstance(asset.get('provenance'), dict) and bool(asset['provenance'].get('prompt')),
139
+ label + identity + ': provenance.prompt required')
140
+ if 'vector_source' in asset:
141
+ vector = source_path(asset['vector_source'], label + identity + ' vector')
142
+ if vector:
143
+ try:
144
+ svg = ET.parse(vector).getroot()
145
+ require(svg.tag in ('svg', '{http://www.w3.org/2000/svg}svg'), label + identity + ': vector source must have SVG root')
146
+ viewbox = [float(n) for n in svg.get('viewBox', '').replace(',', ' ').split()]
147
+ require(numbers(viewbox, 4) and viewbox[2] > 0 and viewbox[3] > 0,
148
+ label + identity + ': SVG requires finite viewBox with positive extents')
149
+ except (ET.ParseError, ValueError, OSError) as error:
150
+ errors.append(label + identity + ': unreadable SVG: ' + str(error))
151
+ path = source_path(asset.get('source'), label + identity)
152
+ if path:
153
+ try:
154
+ with Image.open(path) as image:
155
+ require(image.format == 'PNG', label + identity + ': master must be PNG')
156
+ require(list(image.size) == size, label + identity + ': source dimensions mismatch')
157
+ alpha = image.convert('RGBA').getchannel('A')
158
+ require(alpha.getextrema()[1] > 0, label + identity + ': empty image')
159
+ if asset.get('transparency') is True:
160
+ require(('A' in image.getbands() or 'transparency' in image.info) and alpha.getextrema()[0] < 255,
161
+ label + identity + ': real transparency required')
162
+ elif asset.get('transparency') is False:
163
+ require(alpha.getextrema()[0] == 255, label + identity + ': declared opaque source contains transparency')
164
+ except OSError as error:
165
+ errors.append(label + identity + ': unreadable image: ' + str(error))
166
+ require(actual == expected, label + 'resource/frame contract incomplete or contains unexpected/duplicate entries')
167
+ for key, frames in sequences.items():
168
+ require(frames == list(range(len(frames))), label + str(key) + ': frames must appear in contiguous order from zero')
169
+ for key, values in alignment.items():
170
+ require(all(value == values[0] for value in values), label + str(key) + ': states/frames must share canvas, pivot and logical size')
171
+ audio_ids = set()
172
+ actual_sounds = Counter()
173
+ for sound in sounds:
174
+ identity = sound.get('id')
175
+ require(isinstance(identity, str) and bool(identity) and identity not in audio_ids and identity not in ids,
176
+ label + 'sound ids must be unique and distinct from images')
177
+ if not isinstance(identity, str):
178
+ continue
179
+ audio_ids.add(identity)
180
+ key = (sound.get('sound'), sound.get('variant'))
181
+ if not all(isinstance(v, str) and v for v in key):
182
+ errors.append(label + identity + ': invalid sound/variant')
183
+ continue
184
+ actual_sounds[key] += 1
185
+ source_path(sound.get('source'), label + identity)
186
+ if 'wav' in sound:
187
+ wav = sound['wav']
188
+ if not isinstance(wav, dict):
189
+ errors.append(label + identity + ': wav must be an object')
190
+ else:
191
+ fields = ('sample_rate', 'channels', 'sample_width', 'frames')
192
+ require(all(type(wav.get(k)) is int and wav[k] > 0 for k in fields), label + identity + ': WAV format fields must be positive integers')
193
+ require(type(wav.get('loop')) is bool, label + identity + ': WAV loop must be Boolean')
194
+ file = source_path(wav.get('file'), label + identity + ' WAV')
195
+ if file:
196
+ try:
197
+ with wave.open(str(file), 'rb') as audio:
198
+ actual_format = (audio.getframerate(), audio.getnchannels(), audio.getsampwidth(), audio.getnframes())
199
+ require(actual_format == tuple(wav.get(k) for k in fields), label + identity + ': WAV header differs from contract')
200
+ require(audio.getcomptype() == 'NONE' and audio.getnframes() > 0, label + identity + ': WAV must be nonempty PCM')
201
+ expected_bytes = audio.getnframes() * audio.getnchannels() * audio.getsampwidth()
202
+ # Stream the data to avoid loading an entire music file into memory.
203
+ actual_bytes = 0
204
+ while block := audio.readframes(65536):
205
+ actual_bytes += len(block)
206
+ require(actual_bytes == expected_bytes, label + identity + ': truncated WAV payload')
207
+ except (wave.Error, EOFError, OSError) as error:
208
+ errors.append(label + identity + ': unreadable WAV: ' + str(error))
209
+ require(isinstance(sound.get('reference'), str) and bool(sound['reference']), label + identity + ': recipe/pattern reference required')
210
+ require(sound.get('kind') in ('effect', 'sustained', 'music', 'ambient', 'ui'), label + identity + ': unknown sound kind')
211
+ require(isinstance(sound.get('design'), dict) and bool(sound['design']), label + identity + ': sound design required')
212
+ require(isinstance(sound.get('provenance'), dict) and bool(sound['provenance']), label + identity + ': sound provenance required')
213
+ require(actual_sounds == expected_sounds, label + 'sound contract incomplete or contains unexpected/duplicate variants')
214
+ aliases = group.get('aliases', {})
215
+ require(isinstance(aliases, dict), label + 'aliases must be an object')
216
+ if isinstance(aliases, dict):
217
+ for alias, target in aliases.items():
218
+ require(alias not in ids | audio_ids and isinstance(target, str) and target in ids | audio_ids,
219
+ label + alias + ': alias must directly reference an existing asset or sound without masking an id')
220
+ layers = group.get('parallax', [])
221
+ require(isinstance(layers, list), label + 'parallax must be a list')
222
+ if layers or contract.get('parallax'):
223
+ require(isinstance(layers, list) and len(layers) == 3 and {layer.get('depth') for layer in layers} == {'far', 'middle', 'near'},
224
+ label + 'parallax requires far, middle and near layers')
225
+ if isinstance(layers, list):
226
+ layer_assets = [layer.get('asset') for layer in layers]
227
+ require(len(set(layer_assets)) == 3, label + 'parallax layers must be independent assets')
228
+ orders = {}
229
+ for layer in layers:
230
+ asset = asset_by_id.get(layer.get('asset'))
231
+ require(bool(asset) and asset.get('family') == 'backgrounds', label + 'parallax references unknown/non-background asset')
232
+ require(type(layer.get('transparent')) is bool and asset is not None and asset.get('transparency') == layer.get('transparent'),
233
+ label + 'parallax transparency must match its asset')
234
+ require(layer.get('repeat') in ('none', 'x', 'y', 'xy'), label + 'invalid parallax repeat')
235
+ require(numbers(layer.get('displacement'), 2), label + 'parallax displacement must be finite x/y')
236
+ order = layer.get('order')
237
+ require(type(order) is int, label + 'parallax order must be integer')
238
+ orders[layer.get('depth')] = order
239
+ if all(type(orders.get(k)) is int for k in ('far', 'middle', 'near')):
240
+ require(orders['far'] < orders['middle'] < orders['near'], label + 'parallax orders must increase with depth')
241
+ for event in group.get('events', []):
242
+ require(isinstance(event.get('event'), str) and bool(event['event']), label + 'semantic event required')
243
+ require(event.get('visual') in ids and event.get('sound') in audio_ids, label + 'event references unknown visual/sound')
244
+ require(event.get('timing') in ('discrete', 'sustained', 'music'), label + 'event timing must be discrete, sustained or music (never each frame)')
245
+ counts.append({'set': name, 'contract': group.get('contract'), 'unique_resources': len({k[0] for k in actual}),
246
+ 'png_frames': len(assets), 'sound_variants': len(sounds), 'aliases': len(aliases)})
247
+ return errors, counts
248
+
249
+
250
+ def main():
251
+ parser = argparse.ArgumentParser(description=__doc__)
252
+ parser.add_argument('manifest', type=Path)
253
+ parser.add_argument('--root', type=Path, help='source root; defaults to manifest directory')
254
+ args = parser.parse_args()
255
+ try:
256
+ data = json.loads(args.manifest.read_text(encoding='utf-8'))
257
+ if not isinstance(data, dict):
258
+ raise ValueError('manifest must be an object')
259
+ errors, counts = validate(data, args.root or args.manifest.parent)
260
+ except (ValueError, OSError, TypeError, KeyError, AttributeError) as error:
261
+ errors, counts = ['malformed manifest: ' + str(error)], []
262
+ print(json.dumps({'valid': not errors, 'counts': counts, 'errors': errors}, indent=2))
263
+ raise SystemExit(1 if errors else 0)
264
+
265
+
266
+ if __name__ == '__main__':
267
+ main()
@@ -1,26 +1,32 @@
1
1
  ---
2
2
  name: ti-synthengine
3
- description: "Use when a Titanium app declares ti.synthengine in tiapp.xml, imports require('ti.synthengine'), or asks for generated tones, chords, patterns, retro/game/UI sounds, alarms, sweeps, vibrato, envelopes, panning, gain staging, or troubleshooting with that native module. Covers Android and iOS contract 1.0.0. Do not use for audio-file playback, streaming music, or unrelated synthesizers."
3
+ description: "Use when a Titanium app declares ti.synthengine in tiapp.xml, imports require('ti.synthengine'), or asks for generated tones, chords, patterns, retro/game/UI sounds, alarms, sweeps, vibrato, envelopes (attack, decay, sustain, release), resonant filters, FM bells and electric-piano tones, detune, panning, binaural headphone positioning (spatialX/spatialY), gain staging, engine statistics, render load, the patternend event, or troubleshooting with that native module. Covers Android and iOS contract 1.0.0. Do not use for audio-file playback, streaming music, or unrelated synthesizers."
4
4
  ---
5
5
 
6
6
  # ti.synthengine — sound synthesis for Titanium SDK
7
7
 
8
8
  Act as an audio engineer and mobile sound designer for the native `ti.synthengine` module. Translate an aesthetic request into acoustically reasoned JavaScript that uses only the module's public contract.
9
9
 
10
- This skill is verified against TiSynthEngine **1.0.0** at commit **`6685b81` (2026-08-29)**. Android and iOS expose the same eight public methods, option contracts, defaults, limits, waveform constants, and validation behavior.
10
+ This skill follows the reviewed TiSynthEngine **1.0.0 beta working tree based on `95901c98af5353b86d9dc7c60b29ab9186b038cd` (2026-10-14)**, including uncommitted statistics changes. Eight official Markdown references and the raw statistics comparison record source hashes; [source notes](references/source-notes.md) and [snapshot metadata](references/source-snapshot.json) record the reviewed working-tree fingerprint, maintained navigation additions and exclusions. The reviewed Android and iOS source exposes eleven public methods and one event; this is not a claim about published packages. Inspect `getDefaults().limits` for `spatialX` and `spatialY` before offering binaural controls, and detect statistics methods before calling them; `apiVersion` alone does not distinguish installed beta builds.
11
+
12
+ ## Visual and sound asset collaboration
13
+
14
+ Use `ti-game-assets` to create/edit complete visual and sound asset sets, preserve art/acoustic palettes and provenance, validate alpha/frames/manifests, prepare exports, and coordinate variants and stereo event placement. `ti-game` governs native representation/motion/events; `ti-synthengine` governs synthesis and sound behavior with `ti.synthengine`. Both have equal domain authority. Read their relevant current references rather than duplicating manuals. Art alone does not require installing modules or adding sounds/mechanics.
11
15
 
12
16
  ## Required workflow
13
17
 
14
- Read the references relevant to the task before proposing code. Do not infer option names from Web Audio, another synthesizer, or general DSP libraries.
18
+ Read the references relevant to the task before proposing code. These are source snapshots, not an independent validation oracle: when reviewing the module or updating its official documentation, compare against the native contract and both renderers, then synchronize the references. Do not infer option names from Web Audio, another synthesizer, or general DSP libraries.
15
19
 
16
20
  | Task | Read first |
17
21
  | --- | --- |
18
22
  | Any code using a public method, option, default, limit, or return value | [references/api.md](references/api.md) |
23
+ | Position sounds in a headphone front plane, map spatial controls, or diagnose spatial/pan conflicts | [references/guide.md](references/guide.md#integrate-binaural-in-a-2d-app), [references/api.md](references/api.md), [references/audio-engine.md](references/audio-engine.md) |
19
24
  | Design a sound from an aesthetic description | [references/sound-design.md](references/sound-design.md), then [references/recipes.md](references/recipes.md) for a tuned starting point |
20
25
  | Build retro/arcade effects, a playable instrument, memory pads, layered chords, timbre comparisons, or cancellable repetition | [references/recipes.md](references/recipes.md), plus [references/api.md](references/api.md) |
21
26
  | Learn the official examples for tones, chords, tempo, patterns, noise, validation, startup, and cleanup | [references/examples.md](references/examples.md) |
22
27
  | Install the module, start it safely, or manage screen/app cleanup | [references/guide.md](references/guide.md) |
23
28
  | Choose a mixing profile, voice count, gain, latency strategy, or understand the signal path | [references/audio-engine.md](references/audio-engine.md), [references/recommendations.md](references/recommendations.md) |
29
+ | Query/reset statistics, enable diagnostics, interpret render load/meters, or compare host performance | [references/api.md](references/api.md#getstats), [references/audio-engine.md](references/audio-engine.md#engine-statistics), [references/stats-performance.md](references/stats-performance.md), [raw trials](references/stats-performance.json) |
24
30
  | Diagnose `false`, silence, glitches, distortion, pattern replacement, or route changes | [references/troubleshooting.md](references/troubleshooting.md), [references/api.md](references/api.md) |
25
31
 
26
32
  Cite factual guidance with `[source: references/<file>.md]`. If a relevant reference cannot be consulted, prefix the claim with `FROM_MEMORY (unverified):` instead of filling the gap silently.
@@ -33,7 +39,7 @@ Answer in the user's language and use this order:
33
39
  2. **Safe initialization when needed.** If the surrounding code does not already own a running engine, show `require`, `getDefaults()`, a checked `startEngine()` call, and appropriate cleanup. For a screen startup, follow the post-layout lifecycle in the guide.
34
40
  3. **Production JavaScript.** Return a focused, copyable block with short comments, exact option names, valid types and checked Boolean results.
35
41
 
36
- If the request cannot be represented by this synthesizer—audio-file playback, indefinite loops, per-voice control, streaming, or sample-accurate composition—say so and recommend the appropriate kind of audio engine instead of inventing an API.
42
+ If the request cannot be represented by this synthesizer—audio-file playback, indefinite loops, per-voice control, streaming, or sample-accurate composition—say so and recommend the appropriate kind of audio engine instead of inventing an API. The same goes for reverb, delay, chorus and other effects: the module has no effect bus.
37
43
 
38
44
  ## Contract invariants
39
45
 
@@ -45,15 +51,24 @@ var contract = synth.getDefaults()
45
51
  var WAVE = contract.waveTypes
46
52
  ```
47
53
 
48
- Never hardcode waveform integers when `WAVE.SINE`, `WAVE.SQUARE`, `WAVE.SAWTOOTH`, `WAVE.TRIANGLE`, and `WAVE.NOISE` are available.
54
+ Never hardcode waveform integers when `WAVE.SINE`, `WAVE.SQUARE`, `WAVE.SAWTOOTH`, `WAVE.TRIANGLE`, `WAVE.NOISE`, and `WAVE.WAVETABLE` are available.
49
55
 
50
56
  - Option dictionaries are closed. Unknown or misspelled keys, wrong types, non-finite numbers, fractional values for integer fields, and out-of-range values make the entire call return `false`.
51
57
  - `playTone()` takes at most one pitch source: `note` or `frequency`. If neither is supplied, it defaults to 440 Hz. Never send both.
52
- - `playChord()` requires exactly one of `notes` or `frequencies`. It accepts `panSpread` or `pans`, but no sweep or LFO fields.
53
- - Every sounding `playPattern()` step needs exactly one of `note` or `frequency`; a rest uses exactly `rest: true`. Even a `NOISE` step needs a pitch field, although noise ignores its value. Pattern roots do not accept `duration`.
54
- - `duration` includes attack, the full-level middle, and release. For explicit envelopes, `attack < duration` and `release <= duration - attack`.
55
- - `pan` and `panEnd` range from -1 to 1. `frequencyEnd` is a linear sweep target. `lfoFreq` is the modulation rate in Hz and `lfoDepth` is absolute pitch deviation in Hz, not cents.
58
+ - `playChord()` requires exactly one of `notes` or `frequencies`. It accepts `panSpread` or `pans`, the fixed shaping fields `noiseRate`, `lowPassHz`, `pulseWidth`, `waveTable`, and `bitDepth`, the fixed expressive fields `decay`, `sustain`, `filterType`, `filterHz`, `filterQ`, `fmRatio`, and `fmDepth`, and `detunes`, but no sweep, LFO, `detune`, or `*End` fields.
59
+ - Every sounding `playPattern()` step needs exactly one of `note` or `frequency`; a rest uses exactly `rest: true`. Even a `NOISE` step needs a pitch field, although noise ignores its value. Pattern roots do not accept `duration`. Step `duration` overrides musical timing and controls both voice length and timeline advance; `stepDuration` is only a fallback. Use explicit rest steps for silence between short notes. [source: references/api.md]
60
+ - `duration` includes attack, decay, the held level, and release. For explicit envelopes, `attack < duration` and `release <= duration - attack`.
61
+ - `decay` (integer ms, 0 to 4000) and `sustain` (0 to 1) complete the ADSR envelope. `sustain` below 1 needs a `decay`. `attack + decay <= duration`, and the default 50 ms attack counts toward that budget. The release overlaps the decay and fades from wherever the level is.
62
+ - `filterType` is `none`, `lowpass`, `highpass` or `bandpass`. `filterHz` (20 Hz up to the frequency limit), `filterQ` (0.5 to 10) and `filterHzEnd` (0 or 20 Hz up, an exponential sweep) are rejected while no filter type is active. This filter is separate from, and runs after, the one-pole `lowPassHz`. A chord has no `filterHzEnd`.
63
+ - FM is `fmDepth` (modulation index, 0 to 10), `fmRatio` (0.1 to 16) and an optional `fmDepthEnd`; unlike the other `*End` keys, 0 is a real target, so omit the key for no sweep. Only `WAVE.SINE` can carry FM, and `fmRatio` without a depth is rejected.
64
+ - `detune` (cents, -1200 to 1200) works on `playTone` and `playPattern`. `playChord()` uses `detunes`, an array aligned with the pitches. The detuned pitch, and any `frequencyEnd` target, must stay inside the frequency limits.
65
+ - `patternend` is an event, not a method: it fires once per accepted `playPattern()` with `{ completed: true }` at the natural end, or `{ completed: false }` when a newer pattern replaces it. `stopAll()` and `shutdown()` fire nothing. Remove the listener when the owner closes.
66
+ - `spatialX` (left/right) and `spatialY` (below/above) are finite numbers from -1 to 1 on `playTone`, `playChord`, and `playPattern` roots/steps. Supplying either enables binaural processing, with 0 for the omitted coordinate; omitting both preserves stereo. A chord shares one fixed position. Never combine coordinates with `pan`, `panEnd`, `pans` or `panSpread`, even through pattern inheritance: the call returns `false` with `conflicting_spatial_fields`. [source: references/api.md]
67
+ - A binaural switch and percentage sliders belong to the app. For direct direction sliders, divide signed percentages from -100 to 100 by 100. For amplitude sliders from 0 to 100%, multiply each source's normalized coordinate by the percentage divided by 100; 100% height does not move every source above. Route every effect that should be spatial through that conversion, preserving its synthesis recipe, then send only the coordinates when enabled. When disabled, omit both coordinates; sending two zeros still enables binaural processing. The module has no separate switch option. Positions stay fixed for each event; later events can use updated coordinates. Test with stereo headphones and broadband sounds; the front plane has no rear sources, distance attenuation, room reflections or head tracking. [source: references/api.md]
68
+ - `pan` and `panEnd` range from -1 to 1. `frequencyEnd` accepts 0 through the frequency maximum; targets below 1 Hz disable the linear sweep. `lfoFreq` is the modulation rate in Hz and `lfoDepth` is absolute pitch deviation in Hz, not cents.
69
+ - `WAVE.WAVETABLE` requires `waveTable`: 2 to 64 finite values from -1 to 1, stepped without interpolation. `bitDepth` is 0 or an integer from 2 to 16; `pulseWidth` is 0.05 to 0.95 and only shapes `SQUARE`; `pulseWidthEnd` is 0 or 0.05 to 0.95. For `noiseRate`, `noiseRateEnd`, and `lowPassHz`, 0 means white noise, no sweep, or no filter.
56
70
  - `startEngine()`, `playTone()`, `playChord()`, `playPattern()`, and `setVolume()` return `Boolean`; production code must handle rejection where it matters.
71
+ - `getStats()` returns a snapshot without resetting it; `resetStats()` returns nothing and does not stop playback. `setStatsEnabled(enabled)` accepts a Boolean and returns Boolean acceptance. Detect each method with `typeof synth.<method> === 'function'` when supporting older beta binaries. Basic occupancy/queue/rejection counters remain available with diagnostics off; optional render-load timing and output peak/RMS/limiter measurements default off. Android backend underrun/buffer/latency fields are best-effort and independent of that switch. Preserve `null` for unavailable or unmeasured fields: it is not zero. Poll slowly (for example, 5 Hz), cancel the timer with its owner and disable diagnostics when no owner needs them. Render load is callback elapsed work, not total app CPU; Android host comparisons do not establish iOS/device performance, physical underruns, heat or battery behavior. [source: references/api.md] [source: references/audio-engine.md] [source: references/stats-performance.md]
57
72
  - Only one pattern can remain pending. A new pattern replaces future steps from the previous one. `stopAll()` fades active voices but does not cancel scheduled steps; `shutdown()` does both and frees the stream.
58
73
 
59
74
  ## Acoustic decision guide
@@ -63,21 +78,28 @@ Use these as starting regions, then keep every explicit value inside the API lim
63
78
  | Intent | Starting design |
64
79
  | --- | --- |
65
80
  | Clean UI click or feedback | `SINE` or `TRIANGLE`, roughly 30–120 ms, 1–5 ms attack, short fitted release |
66
- | Retro pickup or melody | `SQUARE`, short notes or a rising `playPattern()` |
81
+ | Retro pickup or melody | `SQUARE`, short notes or a rising `playPattern()`; a narrow `pulseWidth` or a `pulseWidthEnd` sweep thins it, `bitDepth` adds grit |
67
82
  | Retro bass | `TRIANGLE`, lower pitch, short attack and controlled release |
68
- | Hit, hat, explosion texture | `NOISE`, immediate attack and short-to-medium release; layer only when the overlap budget allows |
83
+ | Hit, hat, explosion texture | `NOISE`, immediate attack and short-to-medium release; lower `noiseRate` or `lowPassHz` darkens it; layer only when the overlap budget allows |
84
+ | Coarse DAC-like or buzzy custom voice | `WAVETABLE` with a short asymmetric `waveTable`, optionally with `bitDepth` |
69
85
  | Alarm or urgent cue | `SAWTOOTH`, repeated pitches, a sweep, or vibrato around 4–7 Hz; use larger `lfoDepth` only for an obvious effect |
70
86
  | Laser or sci-fi movement | One `frequency` plus `frequencyEnd`, short attack, fitted release, optional `pan` → `panEnd` |
87
+ | Headphone cue above/right or below/left | Use `spatialX`/`spatialY` on a broadband sound, without stereo pan fields; verify perceived direction with headphones |
71
88
  | Pad or atmosphere | One `playChord()` with `SINE` or `TRIANGLE`, 2000 ms or longer, 300–600 ms attack, 800–1500 ms release when it fits |
89
+ | Plucked string, marimba or keyboard note | `TRIANGLE` or `SINE`, 2 ms attack, `decay` 120–400 ms, `sustain` 0 to 0.3, release that fits |
90
+ | Electric piano, bell or metallic hit | `SINE` with `fmDepth` that falls to `fmDepthEnd: 0`; `fmRatio` 1, 2 or 3.5 for tine-like tones, a non-integer ratio for bells; add `decay` and a low `sustain` |
91
+ | Wah, wind or filter sweep | `SAWTOOTH` or `NOISE` through `filterType: 'bandpass'` or `'lowpass'`, `filterHz` to `filterHzEnd`, `filterQ` 2 to 6 |
92
+ | Thick unison pad or supersaw | `playChord()` with the same pitch repeated and `detunes` of ±5 to ±12 cents; keep the gain staging in mind |
72
93
 
73
94
  A4 is 440 Hz under twelve-tone equal temperament; each octave doubles or halves frequency. Upward sweeps tend to read as activation, reward, or energy gain; downward sweeps tend to read as dismissal, power-down, or impact. Treat pan as supporting detail because many phone speakers collapse the stereo image.
74
95
 
75
- For playable controls, trigger one event when the finger enters a new key, not on every `touchmove`. For simultaneous musical voices, prefer one atomic `playChord()` call; use small cent offsets in the generated frequency array when a layered sound needs beating because the module has no chord-detune option. Cancel app-owned timers before stopping voices or shutting down the engine. [source: references/recipes.md]
96
+ For playable controls, trigger one event when the finger enters a new key, not on every `touchmove`. For simultaneous musical voices, prefer one atomic `playChord()` call; use `detunes` (cents per pitch) when a layered sound needs beating. Cancel app-owned timers before stopping voices or shutting down the engine. [source: references/recipes.md]
76
97
 
77
98
  ## Mixing and lifecycle defaults
78
99
 
79
100
  - Begin with `mixingProfile: 'conservative'`; use `balanced` mainly for one or two voices, `speakerSafe` for demanding small speakers, and `raw` only when the app owns gain staging.
80
101
  - Size `maxVoices` for the largest real overlap, including release tails. Capacity is not loudness.
102
+ - On iOS, the default `audioSession: 'playback'` ignores the silent switch and interrupts other apps' audio. Pass `audioSession: 'ambient'` to `startEngine()` when a game should respect the switch and mix with other apps; Android validates the value and ignores it. Profile, voice count, and session are fixed until `shutdown()`.
81
103
  - Use one engine owner. On a Titanium screen, start after layout settles and call `shutdown()` when the owner closes.
82
104
  - Test on the actual output path. Headphones expose noise and stereo detail; small phone speakers expose weak bass, resonance, and acoustic distortion that a digital limiter cannot repair.
83
105
 
@@ -85,7 +107,9 @@ For playable controls, trigger one event when the finger enters a new key, not o
85
107
 
86
108
  - Re-read the exact method table in `references/api.md`; remove every unsupported property.
87
109
  - Confirm pitch fields are mutually exclusive and numeric values are finite and in range.
88
- - Compute the envelope intersection explicitly.
110
+ - Compute the envelope intersection explicitly, including `attack + decay <= duration`.
111
+ - Check the cross-field rules: `sustain` below 1 needs a `decay`, filter fields need a `filterType`, FM needs a sine and a depth.
112
+ - Check installed spatial limits before using binaural coordinates; remove conflicting stereo fields at both pattern root and step. Omit coordinates when the app switch is off.
89
113
  - Use `WAVE` constants from `getDefaults()`.
90
114
  - Use `playChord()` for one simultaneous musical event instead of several hotter independent tones.
91
115
  - Include startup and cleanup only at the ownership level relevant to the request.