@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.
- package/LICENSE +21 -0
- package/README.md +637 -0
- package/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
- package/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
- package/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
- package/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
- package/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
- package/branding/dist/png/typeflow-logo-128x128.png +0 -0
- package/branding/dist/png/typeflow-logo-16x16.png +0 -0
- package/branding/dist/png/typeflow-logo-256x256.png +0 -0
- package/branding/dist/png/typeflow-logo-512x512.png +0 -0
- package/branding/dist/png/typeflow-logo-64x64.png +0 -0
- package/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
- package/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
- package/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
- package/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
- package/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
- package/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
- package/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
- package/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
- package/branding/gen-logo.ps1 +14 -0
- package/branding/typeflow-logo.svg +162 -0
- package/dist/typeflow/LICENSE +21 -0
- package/dist/typeflow/README.md +231 -0
- package/dist/typeflow/branding/dist/ico/typeflow-logo-16x16.ico +0 -0
- package/dist/typeflow/branding/dist/ico/typeflow-logo-256x256.ico +0 -0
- package/dist/typeflow/branding/dist/ico/typeflow-logo-32x32.ico +0 -0
- package/dist/typeflow/branding/dist/ico/typeflow-logo-48x48.ico +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-1024x1024.png +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-128x128.png +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-16x16.png +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-256x256.png +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-512x512.png +0 -0
- package/dist/typeflow/branding/dist/png/typeflow-logo-64x64.png +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-1024x1024.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-128x128.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-16x16.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-256x256.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-32x32.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-48x48.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-512x512.webp +0 -0
- package/dist/typeflow/branding/dist/webp/typeflow-logo-64x64.webp +0 -0
- package/dist/typeflow/branding/gen-logo.ps1 +14 -0
- package/dist/typeflow/branding/typeflow-logo.svg +162 -0
- package/dist/typeflow/dist/typeflow-caret.cjs +47 -0
- package/dist/typeflow/dist/typeflow-caret.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-caret.esm.js +47 -0
- package/dist/typeflow/dist/typeflow-caret.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-caret.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-caret.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-caret.js +47 -0
- package/dist/typeflow/dist/typeflow-caret.js.map +1 -0
- package/dist/typeflow/dist/typeflow-caret.min.js +2 -0
- package/dist/typeflow/dist/typeflow-caret.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-caret.mjs +47 -0
- package/dist/typeflow/dist/typeflow-caret.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.cjs +109 -0
- package/dist/typeflow/dist/typeflow-debug.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.esm.js +109 -0
- package/dist/typeflow/dist/typeflow-debug.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-debug.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.js +109 -0
- package/dist/typeflow/dist/typeflow-debug.js.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.min.js +2 -0
- package/dist/typeflow/dist/typeflow-debug.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-debug.mjs +109 -0
- package/dist/typeflow/dist/typeflow-debug.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.cjs +66 -0
- package/dist/typeflow/dist/typeflow-extchars.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.esm.js +66 -0
- package/dist/typeflow/dist/typeflow-extchars.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-extchars.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.js +66 -0
- package/dist/typeflow/dist/typeflow-extchars.js.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.min.js +2 -0
- package/dist/typeflow/dist/typeflow-extchars.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-extchars.mjs +66 -0
- package/dist/typeflow/dist/typeflow-extchars.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.cjs +47 -0
- package/dist/typeflow/dist/typeflow-keystroke.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.esm.js +47 -0
- package/dist/typeflow/dist/typeflow-keystroke.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-keystroke.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.js +47 -0
- package/dist/typeflow/dist/typeflow-keystroke.js.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.min.js +2 -0
- package/dist/typeflow/dist/typeflow-keystroke.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-keystroke.mjs +47 -0
- package/dist/typeflow/dist/typeflow-keystroke.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.cjs +76 -0
- package/dist/typeflow/dist/typeflow-metrics.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.esm.js +76 -0
- package/dist/typeflow/dist/typeflow-metrics.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-metrics.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.js +76 -0
- package/dist/typeflow/dist/typeflow-metrics.js.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.min.js +2 -0
- package/dist/typeflow/dist/typeflow-metrics.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-metrics.mjs +76 -0
- package/dist/typeflow/dist/typeflow-metrics.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.cjs +3 -0
- package/dist/typeflow/dist/typeflow-parallel.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.esm.js +3 -0
- package/dist/typeflow/dist/typeflow-parallel.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-parallel.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.js +3 -0
- package/dist/typeflow/dist/typeflow-parallel.js.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.min.js +2 -0
- package/dist/typeflow/dist/typeflow-parallel.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-parallel.mjs +3 -0
- package/dist/typeflow/dist/typeflow-parallel.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.cjs +81 -0
- package/dist/typeflow/dist/typeflow-webaudio.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.esm.js +81 -0
- package/dist/typeflow/dist/typeflow-webaudio.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow-webaudio.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.js +81 -0
- package/dist/typeflow/dist/typeflow-webaudio.js.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.min.js +2 -0
- package/dist/typeflow/dist/typeflow-webaudio.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow-webaudio.mjs +81 -0
- package/dist/typeflow/dist/typeflow-webaudio.mjs.map +1 -0
- package/dist/typeflow/dist/typeflow.cjs +277 -0
- package/dist/typeflow/dist/typeflow.cjs.map +1 -0
- package/dist/typeflow/dist/typeflow.esm.js +277 -0
- package/dist/typeflow/dist/typeflow.esm.js.map +1 -0
- package/dist/typeflow/dist/typeflow.esm.min.js +2 -0
- package/dist/typeflow/dist/typeflow.esm.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow.js +277 -0
- package/dist/typeflow/dist/typeflow.js.map +1 -0
- package/dist/typeflow/dist/typeflow.min.js +2 -0
- package/dist/typeflow/dist/typeflow.min.js.map +1 -0
- package/dist/typeflow/dist/typeflow.mjs +277 -0
- package/dist/typeflow/dist/typeflow.mjs.map +1 -0
- package/dist/typeflow/jsr.jsonc +27 -0
- package/dist/typeflow/package.json +79 -0
- package/dist/typeflow/src/typeflow-audio-presets.js +137 -0
- package/dist/typeflow/src/typeflow-caret.d.ts +65 -0
- package/dist/typeflow/src/typeflow-caret.js +303 -0
- package/dist/typeflow/src/typeflow-debug.d.ts +34 -0
- package/dist/typeflow/src/typeflow-debug.js +223 -0
- package/dist/typeflow/src/typeflow-extchars.d.ts +28 -0
- package/dist/typeflow/src/typeflow-extchars.js +132 -0
- package/dist/typeflow/src/typeflow-keystroke.d.ts +28 -0
- package/dist/typeflow/src/typeflow-keystroke.js +220 -0
- package/dist/typeflow/src/typeflow-metrics.d.ts +38 -0
- package/dist/typeflow/src/typeflow-metrics.js +556 -0
- package/dist/typeflow/src/typeflow-parallel.d.ts +36 -0
- package/dist/typeflow/src/typeflow-parallel.js +157 -0
- package/dist/typeflow/src/typeflow-webaudio.d.ts +83 -0
- package/dist/typeflow/src/typeflow-webaudio.js +517 -0
- package/dist/typeflow/src/typeflow.d.ts +328 -0
- package/dist/typeflow/src/typeflow.js +2119 -0
- package/jsr.jsonc +27 -0
- package/package.json +133 -0
- package/src/typeflow-audio-presets.js +137 -0
- package/src/typeflow-caret.d.ts +65 -0
- package/src/typeflow-caret.js +303 -0
- package/src/typeflow-debug.d.ts +34 -0
- package/src/typeflow-debug.js +223 -0
- package/src/typeflow-extchars.d.ts +28 -0
- package/src/typeflow-extchars.js +132 -0
- package/src/typeflow-keystroke.d.ts +28 -0
- package/src/typeflow-keystroke.js +220 -0
- package/src/typeflow-metrics.d.ts +38 -0
- package/src/typeflow-metrics.js +556 -0
- package/src/typeflow-parallel.d.ts +36 -0
- package/src/typeflow-parallel.js +157 -0
- package/src/typeflow-webaudio.d.ts +83 -0
- package/src/typeflow-webaudio.js +517 -0
- package/src/typeflow.d.ts +328 -0
- 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
|
+
}
|