@maccesar/titools 5.1.1 → 5.2.1

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 (98) hide show
  1. package/CONTRIBUTING.md +69 -0
  2. package/README.md +112 -72
  3. package/agents/ti-pro.md +5 -1
  4. package/commands/ti-audit.md +5 -1
  5. package/docs/examples.md +91 -0
  6. package/docs/knowledge-index.md +57 -0
  7. package/lib/config.js +2 -0
  8. package/lib/downloader.js +1 -1
  9. package/package.json +4 -2
  10. package/skills/purgetss/references/multi-density-images.md +1 -1
  11. package/skills/ti-admob-unified/SKILL.md +58 -0
  12. package/skills/ti-admob-unified/agents/openai.yaml +4 -0
  13. package/skills/ti-admob-unified/assets/example/app.js +1207 -0
  14. package/skills/ti-admob-unified/assets/example/lib/ads.js +646 -0
  15. package/skills/ti-admob-unified/assets/example/library-usage.js +136 -0
  16. package/skills/ti-admob-unified/references/api-reference.md +615 -0
  17. package/skills/ti-admob-unified/references/banners.md +435 -0
  18. package/skills/ti-admob-unified/references/contributing.md +233 -0
  19. package/skills/ti-admob-unified/references/dependencies.md +91 -0
  20. package/skills/ti-admob-unified/references/fullscreen.md +238 -0
  21. package/skills/ti-admob-unified/references/game-monetization.md +148 -0
  22. package/skills/ti-admob-unified/references/getting-started.md +319 -0
  23. package/skills/ti-admob-unified/references/index.md +33 -0
  24. package/skills/ti-admob-unified/references/integration.md +87 -0
  25. package/skills/ti-admob-unified/references/library.md +220 -0
  26. package/skills/ti-admob-unified/references/mediation-parity.md +17 -0
  27. package/skills/ti-admob-unified/references/mediation.md +145 -0
  28. package/skills/ti-admob-unified/references/migration.md +259 -0
  29. package/skills/ti-admob-unified/references/module-changelog.md +84 -0
  30. package/skills/ti-admob-unified/references/module-license.txt +21 -0
  31. package/skills/ti-admob-unified/references/privacy.md +281 -0
  32. package/skills/ti-admob-unified/references/production.md +67 -0
  33. package/skills/ti-admob-unified/references/rewarded-interstitial.md +101 -0
  34. package/skills/ti-admob-unified/references/roadmap.md +143 -0
  35. package/skills/ti-admob-unified/references/source-notes.md +80 -0
  36. package/skills/ti-admob-unified/references/source-snapshot.json +644 -0
  37. package/skills/ti-admob-unified/references/third-party-notices.md +22 -0
  38. package/skills/ti-game/SKILL.md +31 -5
  39. package/skills/ti-game/references/api.md +36 -24
  40. package/skills/ti-game/references/debugging-performance.md +22 -7
  41. package/skills/ti-game/references/examples.md +113 -20
  42. package/skills/ti-game/references/input.md +101 -0
  43. package/skills/ti-game/references/patterns.md +56 -10
  44. package/skills/ti-game/references/physics.md +530 -0
  45. package/skills/ti-game/references/project-setup.md +131 -47
  46. package/skills/ti-game/references/recipes-action.md +149 -18
  47. package/skills/ti-game/references/recipes-adventure-effects.md +47 -4
  48. package/skills/ti-game/references/roadmap.md +11 -4
  49. package/skills/ti-game/references/source-notes.md +81 -0
  50. package/skills/ti-game/references/tilemaps.md +13 -4
  51. package/skills/ti-game-assets/SKILL.md +53 -0
  52. package/skills/ti-game-assets/agents/openai.yaml +4 -0
  53. package/skills/ti-game-assets/references/alpha.md +26 -0
  54. package/skills/ti-game-assets/references/art-direction.md +37 -0
  55. package/skills/ti-game-assets/references/integration.md +49 -0
  56. package/skills/ti-game-assets/references/manifest.example.json +429 -0
  57. package/skills/ti-game-assets/references/manifest.md +39 -0
  58. package/skills/ti-game-assets/references/offline-audio.md +33 -0
  59. package/skills/ti-game-assets/references/prompts.md +33 -0
  60. package/skills/ti-game-assets/references/repeating-textures.md +17 -0
  61. package/skills/ti-game-assets/references/responsible-ads.md +31 -0
  62. package/skills/ti-game-assets/references/sound-assets.md +42 -0
  63. package/skills/ti-game-assets/references/vector-sources.md +21 -0
  64. package/skills/ti-game-assets/scripts/alpha_tools.py +160 -0
  65. package/skills/ti-game-assets/scripts/requirements.txt +1 -0
  66. package/skills/ti-game-assets/scripts/validate_manifest.py +267 -0
  67. package/skills/ti-synthengine/SKILL.md +77 -67
  68. package/skills/ti-synthengine/assets/example/README.md +84 -0
  69. package/skills/ti-synthengine/assets/example/app.js +1178 -0
  70. package/skills/ti-synthengine/assets/example/lib/sequenceOwner.js +83 -0
  71. package/skills/ti-synthengine/assets/example/lib/sequenceScore.js +25 -0
  72. package/skills/ti-synthengine/references/advanced-examples.md +174 -0
  73. package/skills/ti-synthengine/references/android-sim80-correction-evidence.zip +0 -0
  74. package/skills/ti-synthengine/references/android-sim80-correction.json +162 -0
  75. package/skills/ti-synthengine/references/api.md +573 -153
  76. package/skills/ti-synthengine/references/audio-engine.md +272 -124
  77. package/skills/ti-synthengine/references/bypass-physical-recheck-evidence.zip +0 -0
  78. package/skills/ti-synthengine/references/examples.md +355 -106
  79. package/skills/ti-synthengine/references/frontier-android-evidence.zip +0 -0
  80. package/skills/ti-synthengine/references/frontier-ipad-evidence.zip +0 -0
  81. package/skills/ti-synthengine/references/frontier-physical-validation.json +1918 -0
  82. package/skills/ti-synthengine/references/guide.md +426 -75
  83. package/skills/ti-synthengine/references/performance-review-evidence.zip +0 -0
  84. package/skills/ti-synthengine/references/performance-review.json +1146 -0
  85. package/skills/ti-synthengine/references/physical-validation.json +1457 -0
  86. package/skills/ti-synthengine/references/recipes-interaction.md +549 -0
  87. package/skills/ti-synthengine/references/recipes.md +434 -420
  88. package/skills/ti-synthengine/references/recommendations.md +72 -85
  89. package/skills/ti-synthengine/references/sound-design.md +375 -155
  90. package/skills/ti-synthengine/references/source-notes.md +109 -0
  91. package/skills/ti-synthengine/references/source-snapshot.json +452 -0
  92. package/skills/ti-synthengine/references/stats-performance.json +2992 -0
  93. package/skills/ti-synthengine/references/stats-performance.md +82 -0
  94. package/skills/ti-synthengine/references/studio-validation.json +6202 -0
  95. package/skills/ti-synthengine/references/troubleshooting.md +123 -78
  96. package/skills/ti-synthengine/references/unified-bypass-performance.json +22425 -0
  97. package/skills/ti-synthengine/references/unified-validation.json +314 -0
  98. package/AGENTS-VERCEL-RESEARCH.md +0 -175
@@ -0,0 +1,549 @@
1
+ # Instrument, polyphony and expressive recipes
2
+
3
+ These curated TiTools recipes use the same running engine and runtime `WAVE`
4
+ constants as [the arcade recipes](recipes.md). Keep one owner, check native
5
+ acceptance and use their [verification checklist](recipes.md#recipe-verification-checklist).
6
+ The expressive envelope/filter/FM ranges are starting points verified against
7
+ the public contract, not device-auditioned presets. Listen before shipping.
8
+
9
+ ```javascript
10
+ const synth = require('ti.synthengine')
11
+ const WAVE = synth.getDefaults().waveTypes
12
+ ```
13
+
14
+ <!-- TOC-START -->
15
+ ## Contents
16
+
17
+ - [Playable xylophone](#playable-xylophone)
18
+ - [Drag interaction without note storms](#drag-interaction-without-note-storms)
19
+ - [Four-pad memory cues](#four-pad-memory-cues)
20
+ - [Controlled timbre comparison](#controlled-timbre-comparison)
21
+ - [Polyphony and layered chords](#polyphony-and-layered-chords)
22
+ - [Convert MIDI pitch to hertz](#convert-midi-pitch-to-hertz)
23
+ - [Build a wide layered chord](#build-a-wide-layered-chord)
24
+ - [Clean reference versus overlap stress](#clean-reference-versus-overlap-stress)
25
+ - [Cancellable repetition](#cancellable-repetition)
26
+ - [Expressive envelopes, filters and FM](#expressive-envelopes-filters-and-fm)
27
+ - [Plucked marimba note](#plucked-marimba-note)
28
+ - [Kick drum](#kick-drum)
29
+ - [Closed hi-hat](#closed-hi-hat)
30
+ - [Electric piano](#electric-piano)
31
+ - [Bell](#bell)
32
+ - [Wah sweep](#wah-sweep)
33
+ - [Thick unison pad](#thick-unison-pad)
34
+ - [Chaining after a sequence](#chaining-after-a-sequence)
35
+ - [Preset architecture for strict contracts](#preset-architecture-for-strict-contracts)
36
+
37
+ <!-- TOC-END -->
38
+
39
+ ## Playable xylophone
40
+
41
+ Triangle gives a mallet-like balance of a defined pitch and soft upper
42
+ harmonics. A 2 ms attack makes the strike immediate, while a 180 ms release
43
+ lets adjacent bars overlap musically.
44
+
45
+ ```javascript
46
+ const XYLOPHONE = [
47
+ { label: 'C5', frequency: 523.25 },
48
+ { label: 'D5', frequency: 587.33 },
49
+ { label: 'E5', frequency: 659.25 },
50
+ { label: 'F5', frequency: 698.46 },
51
+ { label: 'G5', frequency: 783.99 },
52
+ { label: 'A5', frequency: 880 },
53
+ { label: 'B5', frequency: 987.77 },
54
+ { label: 'C6', frequency: 1046.5 }
55
+ ]
56
+
57
+ function playXylophoneBar (index, pan) {
58
+ const bar = XYLOPHONE[index]
59
+ if (!bar || !isFinite(pan) || pan < -1 || pan > 1) return false
60
+
61
+ return synth.playTone({
62
+ frequency: bar.frequency,
63
+ duration: 420,
64
+ attack: 2,
65
+ release: 180,
66
+ volume: 0.65,
67
+ waveType: WAVE.TRIANGLE,
68
+ pan
69
+ })
70
+ }
71
+ ```
72
+
73
+ Map the bars across the stereo field with
74
+ `pan = -0.8 + (1.6 * index / (barCount - 1))`. Do not make pan carry essential
75
+ meaning because the device may have a mono output.
76
+
77
+ ### Drag interaction without note storms
78
+
79
+ A `touchmove` event may fire many times while the finger remains on one bar.
80
+ Track the last entered bar and strike only when the index changes:
81
+
82
+ ```javascript
83
+ function createBarGesture (playIndex) {
84
+ let tracking = false
85
+ let activeIndex = -1
86
+
87
+ function enter (index) {
88
+ if (!tracking || index < 0 || index === activeIndex) return
89
+ activeIndex = index
90
+ playIndex(index)
91
+ }
92
+
93
+ return {
94
+ start (index) {
95
+ tracking = true
96
+ activeIndex = -1
97
+ enter(index)
98
+ },
99
+ move: enter,
100
+ finish () {
101
+ tracking = false
102
+ activeIndex = -1
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ In a Titanium view:
109
+
110
+ - compute each bar's hit rectangle after layout with `convertPointToView()`;
111
+ - convert move coordinates into one shared container before hit testing;
112
+ - call `gesture.start(index)` on `touchstart` and `gesture.move(index)` only
113
+ after resolving a valid bar;
114
+ - call `gesture.finish()` on both `touchend` and `touchcancel`;
115
+ - keep visual pressed-state counts separate if multiple touches are supported.
116
+
117
+ The audio release may continue after the finger leaves a bar. That is desired
118
+ for a xylophone. `stopAll()` is a global panic: it also cancels every native
119
+ sequence and invalidates shared effect histories. Use an owner's
120
+ `stopSequence({ sequenceId })` when only that sequence should stop.
121
+
122
+ ## Four-pad memory cues
123
+
124
+ Four separated square-wave frequencies remain easy to distinguish at short
125
+ durations. Keep each bank as app metadata, then build a fresh closed options
126
+ object for the module.
127
+
128
+ ```javascript
129
+ const PAD_BANKS = {
130
+ low: [415, 310, 252, 209],
131
+ equalTempered: [391.995, 329.628, 261.626, 195.998]
132
+ }
133
+
134
+ function playMemoryPad (bankName, index) {
135
+ const bank = PAD_BANKS[bankName]
136
+ const frequency = bank && bank[index]
137
+ if (!isFinite(frequency)) return false
138
+
139
+ return synth.playTone({
140
+ frequency: frequency,
141
+ duration: 420,
142
+ attack: 6,
143
+ release: 100,
144
+ volume: 0.62,
145
+ waveType: WAVE.SQUARE
146
+ })
147
+ }
148
+ ```
149
+
150
+ To demonstrate the whole pitch bank as one event, use a chord rather than four
151
+ independent full-gain calls:
152
+
153
+ ```javascript
154
+ function playMemoryBank (bankName) {
155
+ const bank = PAD_BANKS[bankName]
156
+ if (!bank) return false
157
+
158
+ return synth.playChord({
159
+ frequencies: bank.slice(),
160
+ duration: 480,
161
+ attack: 8,
162
+ release: 120,
163
+ volume: 0.56,
164
+ waveType: WAVE.SQUARE,
165
+ pans: [-0.75, -0.25, 0.25, 0.75]
166
+ })
167
+ }
168
+ ```
169
+
170
+ ### Controlled timbre comparison
171
+
172
+ For an honest A/B test, hold pitches, duration, envelope and pan constant.
173
+ Change only waveform and its comparison level. Never pass a profile object
174
+ containing labels or other app metadata directly to `playChord()` because its
175
+ options dictionary is closed.
176
+
177
+ ```javascript
178
+ const TIMBRE_PROFILES = {
179
+ square: { waveType: WAVE.SQUARE, volume: 0.52 },
180
+ sine: { waveType: WAVE.SINE, volume: 0.68 },
181
+ triangle: { waveType: WAVE.TRIANGLE, volume: 0.64 },
182
+ sawtooth: { waveType: WAVE.SAWTOOTH, volume: 0.46 }
183
+ }
184
+
185
+ function auditionTimbre (profileName) {
186
+ const profile = TIMBRE_PROFILES[profileName]
187
+ if (!profile) return false
188
+
189
+ return synth.playChord({
190
+ frequencies: PAD_BANKS.low.slice(),
191
+ duration: 420,
192
+ attack: 8,
193
+ release: 100,
194
+ volume: profile.volume,
195
+ waveType: profile.waveType,
196
+ pans: [-0.75, -0.25, 0.25, 0.75]
197
+ })
198
+ }
199
+ ```
200
+
201
+ ## Polyphony and layered chords
202
+
203
+ ### Convert MIDI pitch to hertz
204
+
205
+ The standard A4 = 440 Hz formula makes it easy to build interval recipes
206
+ without embedding a long frequency table.
207
+
208
+ ```javascript
209
+ function midiToHz (midi) {
210
+ return 440 * Math.pow(2, (midi - 69) / 12)
211
+ }
212
+ ```
213
+
214
+ Detuning needs no conversion: `detune` on a tone or sequence root and `detunes` on a chord take cents, one hundredth of a semitone.
215
+
216
+ ### Build a wide layered chord
217
+
218
+ The interval cycle `[0, 4, 7, ...]` stacks major-triad chord tones across
219
+ octaves. Later layers repeat those tones with small cent offsets, passed as
220
+ `detunes`, which creates width and beating.
221
+
222
+ ```javascript
223
+ const CHORD_INTERVALS = [0, 4, 7, 12, 16, 19, 24, 28, 31, 36]
224
+ const LAYER_DETUNE_CENTS = [0, -8, 8, -12]
225
+
226
+ function normalizeVoiceCount (value) {
227
+ const count = Math.round(Number(value))
228
+ if (!isFinite(count)) return 1
229
+ return Math.max(1, Math.min(32, count))
230
+ }
231
+
232
+ function buildLayeredFrequencies (voiceCount) {
233
+ const count = normalizeVoiceCount(voiceCount)
234
+ const frequencies = []
235
+
236
+ for (let index = 0; index < count; index++) {
237
+ const intervalIndex = index % CHORD_INTERVALS.length
238
+ frequencies.push(midiToHz(60.12 + CHORD_INTERVALS[intervalIndex]))
239
+ }
240
+
241
+ return frequencies
242
+ }
243
+
244
+ function buildLayerDetunes (voiceCount) {
245
+ const count = normalizeVoiceCount(voiceCount)
246
+ const detunes = []
247
+
248
+ for (let index = 0; index < count; index++) {
249
+ const layer = Math.floor(index / CHORD_INTERVALS.length)
250
+ detunes.push(LAYER_DETUNE_CENTS[layer % LAYER_DETUNE_CENTS.length])
251
+ }
252
+
253
+ return detunes
254
+ }
255
+
256
+ function buildSymmetricPans (voiceCount, spread) {
257
+ const count = normalizeVoiceCount(voiceCount)
258
+ const numericSpread = Number(spread)
259
+ const safeSpread = isFinite(numericSpread)
260
+ ? Math.max(0, Math.min(1, numericSpread))
261
+ : 0
262
+ const pans = []
263
+
264
+ if (count === 1) return [0]
265
+
266
+ for (let index = 0; index < count; index++) {
267
+ pans.push(-safeSpread + (2 * safeSpread * index / (count - 1)))
268
+ }
269
+
270
+ return pans
271
+ }
272
+
273
+ function playLayeredChord (voiceCount) {
274
+ const count = normalizeVoiceCount(voiceCount)
275
+
276
+ return synth.playChord({
277
+ frequencies: buildLayeredFrequencies(count),
278
+ detunes: buildLayerDetunes(count),
279
+ duration: 800,
280
+ attack: 35,
281
+ release: 220,
282
+ volume: 0.48,
283
+ waveType: WAVE.SINE,
284
+ pans: buildSymmetricPans(count, 0.8)
285
+ })
286
+ }
287
+ ```
288
+
289
+ The engine must have `maxVoices` at least as large as the intended simultaneous
290
+ chord. Four to twelve voices are a practical musical starting point. Counts
291
+ near 32 are useful for deliberate capacity tests, not as a default.
292
+
293
+ ### Clean reference versus overlap stress
294
+
295
+ A single sustained chord is the clean reference for judging waveform quality.
296
+ If a new 220 ms chord starts every 120 ms, release tails overlap and the summed
297
+ level rises and falls. Limiter pumping is expected in that stress condition;
298
+ it is not proof that a single chord is defective.
299
+
300
+ For a musical pulse, keep the event shorter than its period:
301
+
302
+ ```javascript
303
+ let pulseSequenceId = 0
304
+ let pulseRequested = false
305
+ let pulseStopping = false
306
+ function publishChordPulse () {
307
+ pulseSequenceId = synth.playSequence({timeUnit: 'ms', stepDuration: 260, loop: true,
308
+ voice: {attack: 10, release: 60, volume: 0.42, waveType: WAVE.SINE, pan: 0},
309
+ steps: [{frequencies: buildLayeredFrequencies(4), length: 120}]
310
+ })
311
+ if (!pulseSequenceId) throw new Error('Chord pulse rejected')
312
+ }
313
+ function requestPulseStop () {
314
+ if (!pulseSequenceId || pulseStopping) return
315
+ pulseStopping = true
316
+ if (!synth.stopSequence({sequenceId: pulseSequenceId})) throw new Error('Pulse stop rejected')
317
+ }
318
+ function stopChordPulse () {
319
+ pulseRequested = false
320
+ requestPulseStop()
321
+ }
322
+ function startChordPulse () {
323
+ pulseRequested = true
324
+ if (pulseSequenceId) requestPulseStop()
325
+ else publishChordPulse()
326
+ }
327
+ function onPulseEnd (event) {
328
+ if (event.sequenceId !== pulseSequenceId) return
329
+ pulseSequenceId = 0
330
+ pulseStopping = false
331
+ if (pulseRequested) publishChordPulse()
332
+ }
333
+ synth.addEventListener('sequenceend', onPulseEnd)
334
+ ```
335
+
336
+ This repeated chord uses one centered native sequence. The one-off layered chord above retains its individual stereo pans. `length: 120` plus 60 ms release preserves the 180 ms envelope while attacks remain 260 ms apart.
337
+
338
+ ### Cancellable repetition
339
+
340
+ Every repeating sound has an identified native owner. Repeated start requests coalesce until the matching retirement event frees its slot. Stop its sequence ID and cancel pending restarts when its window closes; no JavaScript timer schedules subsequent notes.
341
+
342
+ ```javascript
343
+ function cleanupAudio () {
344
+ stopChordPulse()
345
+ synth.removeEventListener('sequenceend', onPulseEnd)
346
+ synth.shutdown()
347
+ }
348
+ win.addEventListener('close', cleanupAudio)
349
+ ```
350
+
351
+ Call `shutdown()` only from the screen or service that owns the shared engine.
352
+
353
+ ## Expressive envelopes, filters and FM
354
+
355
+ ### Plucked marimba note
356
+
357
+ A short attack, a decay that falls to a low sustain and a release that fits
358
+ give the struck-and-ring shape of a mallet or plucked string. A sustain below 1
359
+ needs a decay, and `attack + decay` must fit inside `duration`.
360
+
361
+ ```javascript
362
+ synth.playTone({
363
+ note: 'C5',
364
+ duration: 700,
365
+ attack: 2,
366
+ decay: 260,
367
+ sustain: 0.12,
368
+ release: 240,
369
+ waveType: WAVE.TRIANGLE,
370
+ volume: 0.62
371
+ })
372
+ ```
373
+
374
+ ### Kick drum
375
+
376
+ A sine that falls in pitch while its level decays reads as a drum. The pitch
377
+ sweep is linear over the whole event.
378
+
379
+ ```javascript
380
+ synth.playTone({
381
+ frequency: 150,
382
+ frequencyEnd: 45,
383
+ duration: 320,
384
+ attack: 1,
385
+ decay: 140,
386
+ sustain: 0.35,
387
+ release: 140,
388
+ volume: 0.9
389
+ })
390
+ ```
391
+
392
+ ### Closed hi-hat
393
+
394
+ White noise through a high-pass filter, with a decay that drops to a quiet
395
+ tail. The filter fields only work together with a `filterType`.
396
+
397
+ ```javascript
398
+ synth.playTone({
399
+ waveType: WAVE.NOISE,
400
+ duration: 90,
401
+ attack: 1,
402
+ decay: 20,
403
+ sustain: 0.3,
404
+ release: 60,
405
+ filterType: 'highpass',
406
+ filterHz: 6500,
407
+ volume: 0.45
408
+ })
409
+ ```
410
+
411
+ ### Electric piano
412
+
413
+ Frequency modulation with a 1:1 ratio gives a tine-like tone. Letting the
414
+ modulation index fall through `fmDepthEnd` makes the note mellow as it rings,
415
+ which is what the ear expects from a struck tine. FM needs a sine carrier.
416
+
417
+ ```javascript
418
+ synth.playTone({
419
+ note: 'E3',
420
+ duration: 1400,
421
+ attack: 3,
422
+ decay: 700,
423
+ sustain: 0.25,
424
+ release: 500,
425
+ fmRatio: 1,
426
+ fmDepth: 2.2,
427
+ fmDepthEnd: 0.3,
428
+ volume: 0.55
429
+ })
430
+ ```
431
+
432
+ ### Bell
433
+
434
+ A non-integer ratio produces an inharmonic spectrum, and an index that decays
435
+ to nothing leaves a pure ring. `fmDepthEnd: 0` is a real target, so it is
436
+ accepted; omit the key when no sweep is wanted.
437
+
438
+ ```javascript
439
+ synth.playTone({
440
+ note: 'A5',
441
+ duration: 2400,
442
+ attack: 1,
443
+ decay: 1800,
444
+ sustain: 0,
445
+ release: 500,
446
+ fmRatio: 3.5,
447
+ fmDepth: 4,
448
+ fmDepthEnd: 0,
449
+ volume: 0.5
450
+ })
451
+ ```
452
+
453
+ ### Wah sweep
454
+
455
+ A resonant band-pass opening on a sawtooth. The cutoff moves exponentially, so
456
+ equal distances in pitch take equal time. Keep `filterQ` moderate: high values
457
+ make the resonance peak loud enough to engage the limiter.
458
+
459
+ ```javascript
460
+ synth.playTone({
461
+ note: 'A2',
462
+ duration: 900,
463
+ attack: 20,
464
+ release: 200,
465
+ waveType: WAVE.SAWTOOTH,
466
+ filterType: 'bandpass',
467
+ filterHz: 350,
468
+ filterHzEnd: 2200,
469
+ filterQ: 4,
470
+ volume: 0.7
471
+ })
472
+ ```
473
+
474
+ ### Thick unison pad
475
+
476
+ Repeat a pitch and spread the copies a few cents apart. The slow beating
477
+ between them is what makes the sound wide. `detunes` is aligned with the
478
+ pitches and can be shorter than the array.
479
+
480
+ ```javascript
481
+ synth.playChord({
482
+ notes: ['A3', 'A3', 'A3', 'E4', 'E4', 'E4'],
483
+ detunes: [-9, 0, 9, -7, 0, 7],
484
+ pans: [-0.6, 0, 0.6, -0.4, 0, 0.4],
485
+ duration: 2400,
486
+ attack: 350,
487
+ release: 900,
488
+ waveType: WAVE.SAWTOOTH,
489
+ volume: 0.45
490
+ })
491
+ ```
492
+
493
+ ### Chaining after a sequence
494
+
495
+ `sequenceend` carries the accepted `sequenceId` and a reason of `completed` or `stopped`. Only the completed event for this owner should trigger the outro; a delayed event for another ID must not affect it.
496
+
497
+ ```javascript
498
+ let cueSequenceId = 0
499
+ function onSequenceEnd (event) {
500
+ if (event.sequenceId !== cueSequenceId) return
501
+ cueSequenceId = 0
502
+ if (event.reason === 'completed') playOutroCue()
503
+ }
504
+ synth.addEventListener('sequenceend', onSequenceEnd)
505
+ cueSequenceId = synth.playSequence({timeUnit: 'beats', stepDuration: 0.5, loop: false,
506
+ voice: {waveType: WAVE.SQUARE, volume: 0.5},
507
+ steps: [{notes: [72]}, {notes: [76]}, {notes: [79]}, {notes: [84]}]
508
+ })
509
+ if (!cueSequenceId) throw new Error('Cue rejected')
510
+ function cleanupSequenceEvents () {
511
+ if (cueSequenceId) synth.stopSequence({sequenceId: cueSequenceId})
512
+ synth.removeEventListener('sequenceend', onSequenceEnd)
513
+ }
514
+ ```
515
+
516
+ ## Preset architecture for strict contracts
517
+
518
+ Keep descriptive metadata outside the native options object. Build a new
519
+ closed object at the call boundary so keys such as `label`, `category`, or
520
+ `description` never reach the module.
521
+
522
+ ```javascript
523
+ const PRESETS = {
524
+ success: {
525
+ label: 'Success',
526
+ frequency: 760,
527
+ frequencyEnd: 1040,
528
+ waveType: WAVE.TRIANGLE
529
+ }
530
+ }
531
+
532
+ function playPreset (name) {
533
+ const preset = PRESETS[name]
534
+ if (!preset) return false
535
+
536
+ return synth.playTone({
537
+ frequency: preset.frequency,
538
+ frequencyEnd: preset.frequencyEnd,
539
+ duration: 150,
540
+ attack: 3,
541
+ release: 90,
542
+ volume: 0.58,
543
+ waveType: preset.waveType
544
+ })
545
+ }
546
+ ```
547
+
548
+ Avoid `synth.playTone(PRESETS[name])`: the preset contains `label`, which makes
549
+ the native call reject the entire dictionary.