@staticcanvas/typeflow 0.34.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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +637 -0
  3. package/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
  4. package/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
  5. package/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
  6. package/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
  7. package/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
  8. package/branding/dist/png/typeflow-logo-128x128.png +0 -0
  9. package/branding/dist/png/typeflow-logo-16x16.png +0 -0
  10. package/branding/dist/png/typeflow-logo-256x256.png +0 -0
  11. package/branding/dist/png/typeflow-logo-512x512.png +0 -0
  12. package/branding/dist/png/typeflow-logo-64x64.png +0 -0
  13. package/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
  14. package/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
  15. package/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
  16. package/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
  17. package/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
  18. package/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
  19. package/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
  20. package/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
  21. package/branding/gen-logo.ps1 +14 -0
  22. package/branding/typeflow-logo.svg +162 -0
  23. package/dist/typeflow/LICENSE +21 -0
  24. package/dist/typeflow/README.md +231 -0
  25. package/dist/typeflow/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
  26. package/dist/typeflow/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
  27. package/dist/typeflow/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
  28. package/dist/typeflow/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
  29. package/dist/typeflow/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
  30. package/dist/typeflow/branding/dist/png/typeflow-logo-128x128.png +0 -0
  31. package/dist/typeflow/branding/dist/png/typeflow-logo-16x16.png +0 -0
  32. package/dist/typeflow/branding/dist/png/typeflow-logo-256x256.png +0 -0
  33. package/dist/typeflow/branding/dist/png/typeflow-logo-512x512.png +0 -0
  34. package/dist/typeflow/branding/dist/png/typeflow-logo-64x64.png +0 -0
  35. package/dist/typeflow/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
  36. package/dist/typeflow/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
  37. package/dist/typeflow/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
  38. package/dist/typeflow/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
  39. package/dist/typeflow/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
  40. package/dist/typeflow/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
  41. package/dist/typeflow/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
  42. package/dist/typeflow/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
  43. package/dist/typeflow/branding/gen-logo.ps1 +14 -0
  44. package/dist/typeflow/branding/typeflow-logo.svg +162 -0
  45. package/dist/typeflow/dist/typeflow-caret.cjs +47 -0
  46. package/dist/typeflow/dist/typeflow-caret.cjs.map +1 -0
  47. package/dist/typeflow/dist/typeflow-caret.esm.js +47 -0
  48. package/dist/typeflow/dist/typeflow-caret.esm.js.map +1 -0
  49. package/dist/typeflow/dist/typeflow-caret.esm.min.js +2 -0
  50. package/dist/typeflow/dist/typeflow-caret.esm.min.js.map +1 -0
  51. package/dist/typeflow/dist/typeflow-caret.js +47 -0
  52. package/dist/typeflow/dist/typeflow-caret.js.map +1 -0
  53. package/dist/typeflow/dist/typeflow-caret.min.js +2 -0
  54. package/dist/typeflow/dist/typeflow-caret.min.js.map +1 -0
  55. package/dist/typeflow/dist/typeflow-caret.mjs +47 -0
  56. package/dist/typeflow/dist/typeflow-caret.mjs.map +1 -0
  57. package/dist/typeflow/dist/typeflow-debug.cjs +109 -0
  58. package/dist/typeflow/dist/typeflow-debug.cjs.map +1 -0
  59. package/dist/typeflow/dist/typeflow-debug.esm.js +109 -0
  60. package/dist/typeflow/dist/typeflow-debug.esm.js.map +1 -0
  61. package/dist/typeflow/dist/typeflow-debug.esm.min.js +2 -0
  62. package/dist/typeflow/dist/typeflow-debug.esm.min.js.map +1 -0
  63. package/dist/typeflow/dist/typeflow-debug.js +109 -0
  64. package/dist/typeflow/dist/typeflow-debug.js.map +1 -0
  65. package/dist/typeflow/dist/typeflow-debug.min.js +2 -0
  66. package/dist/typeflow/dist/typeflow-debug.min.js.map +1 -0
  67. package/dist/typeflow/dist/typeflow-debug.mjs +109 -0
  68. package/dist/typeflow/dist/typeflow-debug.mjs.map +1 -0
  69. package/dist/typeflow/dist/typeflow-extchars.cjs +66 -0
  70. package/dist/typeflow/dist/typeflow-extchars.cjs.map +1 -0
  71. package/dist/typeflow/dist/typeflow-extchars.esm.js +66 -0
  72. package/dist/typeflow/dist/typeflow-extchars.esm.js.map +1 -0
  73. package/dist/typeflow/dist/typeflow-extchars.esm.min.js +2 -0
  74. package/dist/typeflow/dist/typeflow-extchars.esm.min.js.map +1 -0
  75. package/dist/typeflow/dist/typeflow-extchars.js +66 -0
  76. package/dist/typeflow/dist/typeflow-extchars.js.map +1 -0
  77. package/dist/typeflow/dist/typeflow-extchars.min.js +2 -0
  78. package/dist/typeflow/dist/typeflow-extchars.min.js.map +1 -0
  79. package/dist/typeflow/dist/typeflow-extchars.mjs +66 -0
  80. package/dist/typeflow/dist/typeflow-extchars.mjs.map +1 -0
  81. package/dist/typeflow/dist/typeflow-keystroke.cjs +47 -0
  82. package/dist/typeflow/dist/typeflow-keystroke.cjs.map +1 -0
  83. package/dist/typeflow/dist/typeflow-keystroke.esm.js +47 -0
  84. package/dist/typeflow/dist/typeflow-keystroke.esm.js.map +1 -0
  85. package/dist/typeflow/dist/typeflow-keystroke.esm.min.js +2 -0
  86. package/dist/typeflow/dist/typeflow-keystroke.esm.min.js.map +1 -0
  87. package/dist/typeflow/dist/typeflow-keystroke.js +47 -0
  88. package/dist/typeflow/dist/typeflow-keystroke.js.map +1 -0
  89. package/dist/typeflow/dist/typeflow-keystroke.min.js +2 -0
  90. package/dist/typeflow/dist/typeflow-keystroke.min.js.map +1 -0
  91. package/dist/typeflow/dist/typeflow-keystroke.mjs +47 -0
  92. package/dist/typeflow/dist/typeflow-keystroke.mjs.map +1 -0
  93. package/dist/typeflow/dist/typeflow-metrics.cjs +76 -0
  94. package/dist/typeflow/dist/typeflow-metrics.cjs.map +1 -0
  95. package/dist/typeflow/dist/typeflow-metrics.esm.js +76 -0
  96. package/dist/typeflow/dist/typeflow-metrics.esm.js.map +1 -0
  97. package/dist/typeflow/dist/typeflow-metrics.esm.min.js +2 -0
  98. package/dist/typeflow/dist/typeflow-metrics.esm.min.js.map +1 -0
  99. package/dist/typeflow/dist/typeflow-metrics.js +76 -0
  100. package/dist/typeflow/dist/typeflow-metrics.js.map +1 -0
  101. package/dist/typeflow/dist/typeflow-metrics.min.js +2 -0
  102. package/dist/typeflow/dist/typeflow-metrics.min.js.map +1 -0
  103. package/dist/typeflow/dist/typeflow-metrics.mjs +76 -0
  104. package/dist/typeflow/dist/typeflow-metrics.mjs.map +1 -0
  105. package/dist/typeflow/dist/typeflow-parallel.cjs +3 -0
  106. package/dist/typeflow/dist/typeflow-parallel.cjs.map +1 -0
  107. package/dist/typeflow/dist/typeflow-parallel.esm.js +3 -0
  108. package/dist/typeflow/dist/typeflow-parallel.esm.js.map +1 -0
  109. package/dist/typeflow/dist/typeflow-parallel.esm.min.js +2 -0
  110. package/dist/typeflow/dist/typeflow-parallel.esm.min.js.map +1 -0
  111. package/dist/typeflow/dist/typeflow-parallel.js +3 -0
  112. package/dist/typeflow/dist/typeflow-parallel.js.map +1 -0
  113. package/dist/typeflow/dist/typeflow-parallel.min.js +2 -0
  114. package/dist/typeflow/dist/typeflow-parallel.min.js.map +1 -0
  115. package/dist/typeflow/dist/typeflow-parallel.mjs +3 -0
  116. package/dist/typeflow/dist/typeflow-parallel.mjs.map +1 -0
  117. package/dist/typeflow/dist/typeflow-webaudio.cjs +81 -0
  118. package/dist/typeflow/dist/typeflow-webaudio.cjs.map +1 -0
  119. package/dist/typeflow/dist/typeflow-webaudio.esm.js +81 -0
  120. package/dist/typeflow/dist/typeflow-webaudio.esm.js.map +1 -0
  121. package/dist/typeflow/dist/typeflow-webaudio.esm.min.js +2 -0
  122. package/dist/typeflow/dist/typeflow-webaudio.esm.min.js.map +1 -0
  123. package/dist/typeflow/dist/typeflow-webaudio.js +81 -0
  124. package/dist/typeflow/dist/typeflow-webaudio.js.map +1 -0
  125. package/dist/typeflow/dist/typeflow-webaudio.min.js +2 -0
  126. package/dist/typeflow/dist/typeflow-webaudio.min.js.map +1 -0
  127. package/dist/typeflow/dist/typeflow-webaudio.mjs +81 -0
  128. package/dist/typeflow/dist/typeflow-webaudio.mjs.map +1 -0
  129. package/dist/typeflow/dist/typeflow.cjs +277 -0
  130. package/dist/typeflow/dist/typeflow.cjs.map +1 -0
  131. package/dist/typeflow/dist/typeflow.esm.js +277 -0
  132. package/dist/typeflow/dist/typeflow.esm.js.map +1 -0
  133. package/dist/typeflow/dist/typeflow.esm.min.js +2 -0
  134. package/dist/typeflow/dist/typeflow.esm.min.js.map +1 -0
  135. package/dist/typeflow/dist/typeflow.js +277 -0
  136. package/dist/typeflow/dist/typeflow.js.map +1 -0
  137. package/dist/typeflow/dist/typeflow.min.js +2 -0
  138. package/dist/typeflow/dist/typeflow.min.js.map +1 -0
  139. package/dist/typeflow/dist/typeflow.mjs +277 -0
  140. package/dist/typeflow/dist/typeflow.mjs.map +1 -0
  141. package/dist/typeflow/jsr.jsonc +27 -0
  142. package/dist/typeflow/package.json +79 -0
  143. package/dist/typeflow/src/typeflow-audio-presets.js +137 -0
  144. package/dist/typeflow/src/typeflow-caret.d.ts +65 -0
  145. package/dist/typeflow/src/typeflow-caret.js +303 -0
  146. package/dist/typeflow/src/typeflow-debug.d.ts +34 -0
  147. package/dist/typeflow/src/typeflow-debug.js +223 -0
  148. package/dist/typeflow/src/typeflow-extchars.d.ts +28 -0
  149. package/dist/typeflow/src/typeflow-extchars.js +132 -0
  150. package/dist/typeflow/src/typeflow-keystroke.d.ts +28 -0
  151. package/dist/typeflow/src/typeflow-keystroke.js +220 -0
  152. package/dist/typeflow/src/typeflow-metrics.d.ts +38 -0
  153. package/dist/typeflow/src/typeflow-metrics.js +556 -0
  154. package/dist/typeflow/src/typeflow-parallel.d.ts +36 -0
  155. package/dist/typeflow/src/typeflow-parallel.js +157 -0
  156. package/dist/typeflow/src/typeflow-webaudio.d.ts +83 -0
  157. package/dist/typeflow/src/typeflow-webaudio.js +517 -0
  158. package/dist/typeflow/src/typeflow.d.ts +328 -0
  159. package/dist/typeflow/src/typeflow.js +2119 -0
  160. package/jsr.jsonc +27 -0
  161. package/package.json +133 -0
  162. package/src/typeflow-audio-presets.js +137 -0
  163. package/src/typeflow-caret.d.ts +65 -0
  164. package/src/typeflow-caret.js +303 -0
  165. package/src/typeflow-debug.d.ts +34 -0
  166. package/src/typeflow-debug.js +223 -0
  167. package/src/typeflow-extchars.d.ts +28 -0
  168. package/src/typeflow-extchars.js +132 -0
  169. package/src/typeflow-keystroke.d.ts +28 -0
  170. package/src/typeflow-keystroke.js +220 -0
  171. package/src/typeflow-metrics.d.ts +38 -0
  172. package/src/typeflow-metrics.js +556 -0
  173. package/src/typeflow-parallel.d.ts +36 -0
  174. package/src/typeflow-parallel.js +157 -0
  175. package/src/typeflow-webaudio.d.ts +83 -0
  176. package/src/typeflow-webaudio.js +517 -0
  177. package/src/typeflow.d.ts +328 -0
  178. package/src/typeflow.js +2119 -0
@@ -0,0 +1,517 @@
1
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
2
+ * @description TypeFlow WebAudio - Procedural Audio Synthesis for Typing Interfaces.
3
+ * Synthesizes zero-asset, real-time procedural keystrokes including mechanical switches,
4
+ * vintage teletype strikes, retro terminal beeps, and FM cyber synths with spatial panning.
5
+ *
6
+ * @module @staticcanvas/typeflow/webaudio
7
+ */
8
+
9
+ import {
10
+ AUDIO_PRESETS,
11
+ AUDIO_PRESET_ALIASES,
12
+ AUDIO_PRESET_SCHEMA_VERSION,
13
+ validateAudioPreset,
14
+ } from './typeflow-audio-presets.js';
15
+ export { AUDIO_PRESETS, AUDIO_PRESET_ALIASES, AUDIO_PRESET_SCHEMA_VERSION, validateAudioPreset };
16
+
17
+ export class TypeFlowWebAudio {
18
+ static ctx = null;
19
+ static masterGain = null;
20
+ static isMuted = false;
21
+ static volume = 0.25;
22
+ static maxPolyphony = 24;
23
+ static customPresets = new Map();
24
+ static activeVoices = new Set();
25
+ static panners = new Map();
26
+ static unlockHandler = null;
27
+
28
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
29
+ * @description Initializes or returns the shared Web Audio Context singleton.
30
+ * Automatically attaches unlock listeners to resume audio on the first user interaction.
31
+ * @returns {AudioContext | null} The active AudioContext instance or null in non-browser environments.
32
+ *
33
+ * -------------
34
+ *
35
+ * @example
36
+ * const ctx = TypeFlowWebAudio.getContext();
37
+ * console.log('Audio state:', ctx?.state);
38
+ */
39
+ static getContext() {
40
+ if (typeof window === 'undefined') return null;
41
+
42
+ if (!TypeFlowWebAudio.ctx) {
43
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
44
+ if (!AudioCtx) return null;
45
+
46
+ const activation = window.navigator?.userActivation;
47
+ if (activation && !activation.isActive && !activation.hasBeenActive) {
48
+ if (!TypeFlowWebAudio.unlockHandler) {
49
+ const unlock = () => {
50
+ window.removeEventListener('pointerdown', unlock);
51
+ window.removeEventListener('keydown', unlock);
52
+ TypeFlowWebAudio.unlockHandler = null;
53
+ TypeFlowWebAudio.getContext();
54
+ };
55
+ TypeFlowWebAudio.unlockHandler = unlock;
56
+ window.addEventListener('pointerdown', unlock, { once: true });
57
+ window.addEventListener('keydown', unlock, { once: true });
58
+ }
59
+ return null;
60
+ }
61
+
62
+ try {
63
+ TypeFlowWebAudio.ctx = new AudioCtx();
64
+ } catch {
65
+ return null;
66
+ }
67
+ TypeFlowWebAudio.masterGain = TypeFlowWebAudio.ctx.createGain();
68
+ TypeFlowWebAudio.masterGain.gain.setValueAtTime(
69
+ TypeFlowWebAudio.isMuted ? 0 : TypeFlowWebAudio.volume,
70
+ TypeFlowWebAudio.ctx.currentTime
71
+ );
72
+ TypeFlowWebAudio.masterGain.connect(TypeFlowWebAudio.ctx.destination);
73
+ }
74
+
75
+ if (TypeFlowWebAudio.ctx.state === 'suspended') {
76
+ TypeFlowWebAudio.ctx.resume().catch(() => {});
77
+ }
78
+
79
+ return TypeFlowWebAudio.ctx;
80
+ }
81
+
82
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
83
+ * @description Sets the master volume level for synthesized keystrokes.
84
+ * @param {number} vol - Volume level from 0.0 (silent) to 1.0 (maximum).
85
+ * @returns {void}
86
+ *
87
+ * -------------
88
+ *
89
+ * @Properties
90
+ * @param {number} vol - Floating-point number between 0.0 and 1.0.
91
+ *
92
+ * -------------
93
+ *
94
+ * @example
95
+ * TypeFlowWebAudio.setVolume(0.15); // Set subtle background volume
96
+ */
97
+ static setVolume(vol) {
98
+ TypeFlowWebAudio.volume = Math.max(0, Math.min(1, vol));
99
+ if (TypeFlowWebAudio.masterGain && TypeFlowWebAudio.ctx && !TypeFlowWebAudio.isMuted) {
100
+ TypeFlowWebAudio.masterGain.gain.setValueAtTime(
101
+ TypeFlowWebAudio.volume,
102
+ TypeFlowWebAudio.ctx.currentTime
103
+ );
104
+ }
105
+ }
106
+
107
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
108
+ * @description Toggles or forces the mute state of procedural keystroke audio.
109
+ * @param {boolean} [force] - Optional boolean to explicitly force muted (true) or unmuted (false).
110
+ * @returns {void}
111
+ *
112
+ * -------------
113
+ *
114
+ * @Properties
115
+ * @param {boolean} [force] - If omitted, flips the current mute state.
116
+ *
117
+ * -------------
118
+ *
119
+ * @example
120
+ * TypeFlowWebAudio.mute(); // Toggle mute
121
+ * TypeFlowWebAudio.mute(true); // Force mute
122
+ * TypeFlowWebAudio.mute(false); // Force unmute
123
+ */
124
+ static mute(force) {
125
+ TypeFlowWebAudio.isMuted = force !== undefined ? force : !TypeFlowWebAudio.isMuted;
126
+ if (TypeFlowWebAudio.masterGain && TypeFlowWebAudio.ctx) {
127
+ TypeFlowWebAudio.masterGain.gain.setValueAtTime(
128
+ TypeFlowWebAudio.isMuted ? 0 : TypeFlowWebAudio.volume,
129
+ TypeFlowWebAudio.ctx.currentTime
130
+ );
131
+ }
132
+ }
133
+
134
+ /** Registers a validated instance-safe preset. Built-in collisions require `overwrite: true`. */
135
+ static registerAudioPreset(name, config, options = {}) {
136
+ const key = String(name ?? '')
137
+ .trim()
138
+ .toLowerCase();
139
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(key))
140
+ throw new TypeError('TypeFlowWebAudio: invalid preset name.');
141
+ if (
142
+ !options.overwrite &&
143
+ (key in AUDIO_PRESETS ||
144
+ key in AUDIO_PRESET_ALIASES ||
145
+ TypeFlowWebAudio.customPresets.has(key))
146
+ ) {
147
+ throw new Error(`TypeFlowWebAudio: preset \`${key}\` already exists.`);
148
+ }
149
+ TypeFlowWebAudio.customPresets.set(key, validateAudioPreset(config));
150
+ return key;
151
+ }
152
+
153
+ /** Removes a custom preset. Immutable built-ins cannot be removed. */
154
+ static removeAudioPreset(name) {
155
+ return TypeFlowWebAudio.customPresets.delete(
156
+ String(name ?? '')
157
+ .trim()
158
+ .toLowerCase()
159
+ );
160
+ }
161
+
162
+ /** Removes every custom preset without affecting immutable built-ins. */
163
+ static resetAudioPresets() {
164
+ TypeFlowWebAudio.customPresets.clear();
165
+ }
166
+
167
+ /** Returns a frozen custom or built-in preset, resolving compatibility aliases. */
168
+ static getAudioPreset(name) {
169
+ const key = String(name ?? '')
170
+ .trim()
171
+ .toLowerCase();
172
+ if (TypeFlowWebAudio.customPresets.has(key)) return TypeFlowWebAudio.customPresets.get(key);
173
+ return AUDIO_PRESETS[AUDIO_PRESET_ALIASES[key] ?? key] ?? null;
174
+ }
175
+
176
+ /** Stops and disconnects a scoped set of voices. */
177
+ static stopVoices(voices) {
178
+ Array.from(voices).forEach((voice) => {
179
+ try {
180
+ voice.oscillator.stop();
181
+ } catch {
182
+ /* already stopped */
183
+ }
184
+ voice.nodes.forEach((node) => {
185
+ try {
186
+ node.disconnect();
187
+ } catch {
188
+ /* already disconnected */
189
+ }
190
+ });
191
+ voice.scope?.delete(voice);
192
+ TypeFlowWebAudio.activeVoices.delete(voice);
193
+ });
194
+ }
195
+
196
+ /** Stops and disconnects all voices created by this extension. */
197
+ static stopAll() {
198
+ TypeFlowWebAudio.stopVoices(TypeFlowWebAudio.activeVoices);
199
+ }
200
+
201
+ /** Tears down voices and the shared AudioContext so repeated setup is clean. */
202
+ static async destroy() {
203
+ TypeFlowWebAudio.stopAll();
204
+ if (TypeFlowWebAudio.unlockHandler && typeof window !== 'undefined') {
205
+ window.removeEventListener('pointerdown', TypeFlowWebAudio.unlockHandler);
206
+ window.removeEventListener('keydown', TypeFlowWebAudio.unlockHandler);
207
+ TypeFlowWebAudio.unlockHandler = null;
208
+ }
209
+ try {
210
+ TypeFlowWebAudio.masterGain?.disconnect();
211
+ } catch {
212
+ /* already disconnected */
213
+ }
214
+ TypeFlowWebAudio.panners.forEach((panner) => {
215
+ try {
216
+ panner.disconnect();
217
+ } catch {
218
+ /* already disconnected */
219
+ }
220
+ });
221
+ TypeFlowWebAudio.panners.clear();
222
+ if (TypeFlowWebAudio.ctx?.close) await TypeFlowWebAudio.ctx.close().catch(() => {});
223
+ TypeFlowWebAudio.ctx = null;
224
+ TypeFlowWebAudio.masterGain = null;
225
+ }
226
+
227
+ static playPreset(ctx, config, options, destination) {
228
+ const action = options.action === 'erase' ? 'erase' : 'type';
229
+ const now = ctx.currentTime;
230
+ for (const layer of config.actions[action]) {
231
+ while (TypeFlowWebAudio.activeVoices.size >= TypeFlowWebAudio.maxPolyphony) {
232
+ const oldest = TypeFlowWebAudio.activeVoices.values().next().value;
233
+ try {
234
+ oldest.oscillator.stop();
235
+ } catch {
236
+ /* already stopped */
237
+ }
238
+ oldest.nodes.forEach((node) => {
239
+ try {
240
+ node.disconnect();
241
+ } catch {
242
+ /* already disconnected */
243
+ }
244
+ });
245
+ oldest.scope?.delete(oldest);
246
+ TypeFlowWebAudio.activeVoices.delete(oldest);
247
+ }
248
+ const variance = options.pitchVariance ?? layer.detune.variance;
249
+ const jitter = 1 + (Math.random() * 2 - 1) * variance;
250
+ const oscillator = ctx.createOscillator();
251
+ const gain = ctx.createGain();
252
+ const nodes = [oscillator, gain];
253
+ oscillator.type = layer.oscillator;
254
+ oscillator.detune.setValueAtTime(layer.detune.base, now);
255
+ oscillator.frequency.setValueAtTime(layer.frequency.start * jitter, now);
256
+ oscillator.frequency.exponentialRampToValueAtTime(
257
+ layer.frequency.end * jitter,
258
+ now + layer.duration
259
+ );
260
+ gain.gain.setValueAtTime(layer.gain.start, now);
261
+ gain.gain.exponentialRampToValueAtTime(layer.gain.end, now + layer.duration);
262
+ oscillator.connect(gain);
263
+ let tail = gain;
264
+ if (layer.filter && typeof ctx.createBiquadFilter === 'function') {
265
+ const filter = ctx.createBiquadFilter();
266
+ filter.type = layer.filter.type;
267
+ filter.frequency.setValueAtTime(layer.filter.frequency, now);
268
+ filter.Q.setValueAtTime(layer.filter.q, now);
269
+ gain.connect(filter);
270
+ tail = filter;
271
+ nodes.push(filter);
272
+ }
273
+ tail.connect(destination);
274
+ const voice = { oscillator, nodes, scope: options.voiceScope };
275
+ TypeFlowWebAudio.activeVoices.add(voice);
276
+ options.voiceScope?.add(voice);
277
+ oscillator.addEventListener?.(
278
+ 'ended',
279
+ () => {
280
+ nodes.forEach((node) => {
281
+ try {
282
+ node.disconnect();
283
+ } catch {
284
+ /* already disconnected */
285
+ }
286
+ });
287
+ options.voiceScope?.delete(voice);
288
+ TypeFlowWebAudio.activeVoices.delete(voice);
289
+ },
290
+ { once: true }
291
+ );
292
+ oscillator.start(now);
293
+ oscillator.stop(now + layer.duration);
294
+ }
295
+ }
296
+
297
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
298
+ * @description Synthesizes and plays a procedural audio keystroke sound in real time without audio assets.
299
+ * @param {string} [profile='mechanical-blue'] - Sound profile name ('mechanical-blue', 'mechanical-red', 'mechanical-brown', 'teletype', 'cyber-synth', 'terminal-8bit', 'bubble').
300
+ * @param {Object} [options={}] - Keystroke playback parameters.
301
+ * @returns {void}
302
+ *
303
+ * -------------
304
+ *
305
+ * @Properties
306
+ * @param {string} [profile='mechanical-blue'] - Synthesizer profile.
307
+ * @param {string} [options.char=' '] - Typed character (differentiates spacebar, return bells, normal keys).
308
+ * @param {number} [options.pan=0] - Stereo pan from -1.0 (full left) to +1.0 (full right).
309
+ * @param {number} [options.pitchVariance=0.06] - Organic pitch variation percentage (±0.06 = ±6%).
310
+ *
311
+ * -------------
312
+ *
313
+ * @example
314
+ *
315
+ * // Play a clicky switch sound
316
+ * TypeFlowWebAudio.play('mechanical-blue');
317
+ *
318
+ * // Play a cyber synth keystroke panned slightly to the right
319
+ * TypeFlowWebAudio.play('cyber-synth', { char: 'A', pan: 0.3 });
320
+ *
321
+ * // Play a teletype carriage bell on newline
322
+ * TypeFlowWebAudio.play('teletype', { char: '\n' });
323
+ */
324
+ static play(profile = 'mechanical-blue', options = {}) {
325
+ if (
326
+ options.respectReducedMotion === true &&
327
+ typeof window !== 'undefined' &&
328
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
329
+ )
330
+ return;
331
+ const ctx = TypeFlowWebAudio.getContext();
332
+ if (!ctx || TypeFlowWebAudio.isMuted) return;
333
+
334
+ const t = ctx.currentTime;
335
+ const char = options.char || ' ';
336
+ const jitter = 1 + (Math.random() * 2 - 1) * (options.pitchVariance ?? 0.06);
337
+
338
+ // Optional spatial stereo panner
339
+ let dest = TypeFlowWebAudio.masterGain;
340
+ if (typeof ctx.createStereoPanner === 'function' && typeof options.pan === 'number') {
341
+ const pan = Math.max(-1, Math.min(1, options.pan));
342
+ let panner = TypeFlowWebAudio.panners.get(pan);
343
+ if (!panner) {
344
+ panner = ctx.createStereoPanner();
345
+ panner.pan.setValueAtTime(pan, t);
346
+ panner.connect(TypeFlowWebAudio.masterGain);
347
+ TypeFlowWebAudio.panners.set(pan, panner);
348
+ }
349
+ dest = panner;
350
+ }
351
+
352
+ const normProfile = String(profile).toLowerCase();
353
+ const presetConfig = TypeFlowWebAudio.getAudioPreset(normProfile);
354
+ if (presetConfig) {
355
+ TypeFlowWebAudio.playPreset(ctx, presetConfig, options, dest);
356
+ return;
357
+ }
358
+
359
+ // 1. Mechanical Clicky Switch (Blue style)
360
+ if (normProfile === 'mechanical-blue' || normProfile === 'clicky') {
361
+ const isSpace = char === ' ' || char === '\n';
362
+ const baseFreq = isSpace ? 140 : 200;
363
+
364
+ // Click snap (high transient)
365
+ const snapOsc = ctx.createOscillator();
366
+ const snapGain = ctx.createGain();
367
+ snapOsc.type = 'triangle';
368
+ snapOsc.frequency.setValueAtTime((isSpace ? 2800 : 3400) * jitter, t);
369
+ snapGain.gain.setValueAtTime(0.2, t);
370
+ snapGain.gain.exponentialRampToValueAtTime(0.001, t + 0.008);
371
+ snapOsc.connect(snapGain);
372
+ snapGain.connect(dest);
373
+ snapOsc.start(t);
374
+ snapOsc.stop(t + 0.008);
375
+
376
+ // Bottom-out thud (low resonant body)
377
+ const thudOsc = ctx.createOscillator();
378
+ const thudGain = ctx.createGain();
379
+ thudOsc.type = 'sine';
380
+ thudOsc.frequency.setValueAtTime(baseFreq * jitter, t);
381
+ thudOsc.frequency.exponentialRampToValueAtTime(50, t + 0.035);
382
+ thudGain.gain.setValueAtTime(0.35, t);
383
+ thudGain.gain.exponentialRampToValueAtTime(0.001, t + 0.035);
384
+ thudOsc.connect(thudGain);
385
+ thudGain.connect(dest);
386
+ thudOsc.start(t);
387
+ thudOsc.stop(t + 0.035);
388
+ }
389
+ // 2. Mechanical Linear / Tactile (Red / Brown style)
390
+ else if (
391
+ normProfile === 'mechanical-red' ||
392
+ normProfile === 'mechanical-brown' ||
393
+ normProfile === 'tactile' ||
394
+ normProfile === 'mechanical'
395
+ ) {
396
+ const thudOsc = ctx.createOscillator();
397
+ const thudGain = ctx.createGain();
398
+ thudOsc.type = normProfile.includes('brown') ? 'triangle' : 'sine';
399
+ thudOsc.frequency.setValueAtTime(220 * jitter, t);
400
+ thudOsc.frequency.exponentialRampToValueAtTime(70, t + 0.028);
401
+ thudGain.gain.setValueAtTime(0.28, t);
402
+ thudGain.gain.exponentialRampToValueAtTime(0.001, t + 0.028);
403
+ thudOsc.connect(thudGain);
404
+ thudGain.connect(dest);
405
+ thudOsc.start(t);
406
+ thudOsc.stop(t + 0.028);
407
+ }
408
+ // 3. Vintage Teletype / Solenoid Strike & Carriage Bell
409
+ else if (
410
+ normProfile === 'teletype' ||
411
+ normProfile === 'vintage' ||
412
+ normProfile === 'typewriter'
413
+ ) {
414
+ if (char === '\n') {
415
+ const bellOsc = ctx.createOscillator();
416
+ const bellGain = ctx.createGain();
417
+ bellOsc.type = 'sine';
418
+ bellOsc.frequency.setValueAtTime(1760, t);
419
+ bellGain.gain.setValueAtTime(0.3, t);
420
+ bellGain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
421
+ bellOsc.connect(bellGain);
422
+ bellGain.connect(dest);
423
+ bellOsc.start(t);
424
+ bellOsc.stop(t + 0.35);
425
+ } else {
426
+ const clackOsc = ctx.createOscillator();
427
+ const clackGain = ctx.createGain();
428
+ clackOsc.type = 'square';
429
+ clackOsc.frequency.setValueAtTime(320 * jitter, t);
430
+ clackOsc.frequency.exponentialRampToValueAtTime(90, t + 0.022);
431
+ clackGain.gain.setValueAtTime(0.18, t);
432
+ clackGain.gain.exponentialRampToValueAtTime(0.001, t + 0.022);
433
+ clackOsc.connect(clackGain);
434
+ clackGain.connect(dest);
435
+ clackOsc.start(t);
436
+ clackOsc.stop(t + 0.022);
437
+ }
438
+ }
439
+ // 4. Sci-Fi Cyber Synth / Laser Chirp
440
+ else if (normProfile === 'cyber-synth' || normProfile === 'synth' || normProfile === 'neon') {
441
+ const osc = ctx.createOscillator();
442
+ const gain = ctx.createGain();
443
+ osc.type = 'sawtooth';
444
+ osc.frequency.setValueAtTime(950 * jitter, t);
445
+ osc.frequency.exponentialRampToValueAtTime(180, t + 0.045);
446
+ gain.gain.setValueAtTime(0.12, t);
447
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
448
+ osc.connect(gain);
449
+ gain.connect(dest);
450
+ osc.start(t);
451
+ osc.stop(t + 0.045);
452
+ }
453
+ // 5. Retro 8-bit Terminal Beep
454
+ else if (normProfile === 'terminal-8bit' || normProfile === '8bit' || normProfile === 'beep') {
455
+ const osc = ctx.createOscillator();
456
+ const gain = ctx.createGain();
457
+ osc.type = 'square';
458
+ osc.frequency.setValueAtTime((char === ' ' ? 440 : 880) * jitter, t);
459
+ gain.gain.setValueAtTime(0.08, t);
460
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.025);
461
+ osc.connect(gain);
462
+ gain.connect(dest);
463
+ osc.start(t);
464
+ osc.stop(t + 0.025);
465
+ }
466
+ // 6. Liquid Bubble Pop
467
+ else if (normProfile === 'bubble' || normProfile === 'pop') {
468
+ const osc = ctx.createOscillator();
469
+ const gain = ctx.createGain();
470
+ osc.type = 'sine';
471
+ osc.frequency.setValueAtTime(450 * jitter, t);
472
+ osc.frequency.exponentialRampToValueAtTime(1100 * jitter, t + 0.03);
473
+ gain.gain.setValueAtTime(0.25, t);
474
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.035);
475
+ osc.connect(gain);
476
+ gain.connect(dest);
477
+ osc.start(t);
478
+ osc.stop(t + 0.035);
479
+ }
480
+ }
481
+ }
482
+
483
+ /** * * * * * * * * * * * * * * * * * * * * * * * * * * **
484
+ * @description Creates an audio callback function configured for TypeFlow's `audio` option.
485
+ * @param {string} [profile='mechanical-blue'] - Sound profile identifier.
486
+ * @param {Object} [options={}] - Custom audio synthesis options.
487
+ * @returns {() => void} Zero-argument callback function invoked per keystroke.
488
+ *
489
+ * -------------
490
+ *
491
+ * @Properties
492
+ * @param {string} [profile='mechanical-blue'] - Sound profile name.
493
+ * @param {number} [options.pan=0] - Stereo pan (-1.0 to 1.0).
494
+ * @param {number} [options.pitchVariance=0.06] - Random pitch variance.
495
+ *
496
+ * -------------
497
+ *
498
+ * @example
499
+ * import { TypeFlow } from '@staticcanvas/typeflow';
500
+ * import { createKeystrokeAudio } from '@staticcanvas/typeflow/webaudio';
501
+ *
502
+ * TypeFlow.type('#headline', 'Mechanical Sound Effects', {
503
+ * audio: createKeystrokeAudio('mechanical-blue', { pan: 0.1 }),
504
+ * speed: 40,
505
+ * });
506
+ */
507
+ export function createKeystrokeAudio(profile = 'mechanical-blue', options = {}) {
508
+ const voiceScope = new Set();
509
+ const play = () => TypeFlowWebAudio.play(profile, { ...options, voiceScope });
510
+ play.stop = () => TypeFlowWebAudio.stopVoices(voiceScope);
511
+ return play;
512
+ }
513
+
514
+ if (typeof window !== 'undefined') {
515
+ window.TypeFlowWebAudio = TypeFlowWebAudio;
516
+ window.createKeystrokeAudio = createKeystrokeAudio;
517
+ }