tuneframes 0.1.1 → 0.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 (80) hide show
  1. package/LICENSE +166 -166
  2. package/README.md +158 -55
  3. package/examples/example-ai-dj-chill.html +167 -0
  4. package/examples/example-ai-dj-dark.html +167 -0
  5. package/examples/example-ai-dj-energetic.html +167 -0
  6. package/examples/example-ai-dj-happy.html +167 -0
  7. package/examples/example-ai-dj.html +167 -0
  8. package/examples/example-ambient.html +63 -63
  9. package/examples/example-ambient.mp3 +0 -0
  10. package/examples/example-bass.html +47 -47
  11. package/examples/example-lofi.html +45 -45
  12. package/examples/example-lofi.mp3 +0 -0
  13. package/examples/example-minimal.html +20 -20
  14. package/examples/example-orchestral.html +67 -67
  15. package/examples/example-orchestral.mp3 +0 -0
  16. package/examples/example-piano.html +52 -52
  17. package/examples/example-piano.mp3 +0 -0
  18. package/examples/example-techno.html +69 -69
  19. package/examples/example-techno.mp3 +0 -0
  20. package/package.json +42 -37
  21. package/registry/presets/bass-electric.html +67 -0
  22. package/registry/presets/bass-saw.html +22 -0
  23. package/registry/presets/chord-progression.html +22 -0
  24. package/registry/presets/drums-808.html +85 -0
  25. package/registry/presets/drums-lofi.html +35 -0
  26. package/registry/presets/lead-piano.html +26 -0
  27. package/registry/presets/piano-salamander.html +69 -0
  28. package/registry/presets/reverb-warm.html +11 -0
  29. package/registry/samples.json +226 -0
  30. package/skills/audio-ambient/SKILL.md +141 -0
  31. package/skills/audio-ambient/example.html +113 -0
  32. package/skills/audio-boss-battle/SKILL.md +157 -0
  33. package/skills/audio-boss-battle/example.html +185 -0
  34. package/skills/audio-boss-battle/example.mp3 +0 -0
  35. package/skills/audio-chillwave/SKILL.md +142 -0
  36. package/skills/audio-chillwave/example.html +144 -0
  37. package/skills/audio-cinematic/SKILL.md +147 -0
  38. package/skills/audio-cinematic/example.html +123 -0
  39. package/skills/audio-classical/SKILL.md +138 -0
  40. package/skills/audio-classical/example.html +145 -0
  41. package/skills/audio-dnb/SKILL.md +142 -0
  42. package/skills/audio-dnb/example.html +124 -0
  43. package/skills/audio-downtempo/SKILL.md +152 -0
  44. package/skills/audio-downtempo/example.html +164 -0
  45. package/skills/audio-folk/SKILL.md +139 -0
  46. package/skills/audio-folk/example.html +132 -0
  47. package/skills/audio-funk/SKILL.md +149 -0
  48. package/skills/audio-funk/example.html +144 -0
  49. package/skills/audio-future-bass/SKILL.md +163 -0
  50. package/skills/audio-future-bass/example.html +164 -0
  51. package/skills/audio-hip-hop/SKILL.md +133 -0
  52. package/skills/audio-hip-hop/example.html +129 -0
  53. package/skills/audio-house/SKILL.md +147 -0
  54. package/skills/audio-house/example.html +128 -0
  55. package/skills/audio-indie-pop/SKILL.md +150 -0
  56. package/skills/audio-indie-pop/example.html +121 -0
  57. package/skills/audio-jazz/SKILL.md +141 -0
  58. package/skills/audio-jazz/example.html +146 -0
  59. package/skills/audio-lofi/SKILL.md +140 -0
  60. package/skills/audio-lofi/example.html +135 -0
  61. package/skills/audio-minimal/SKILL.md +155 -0
  62. package/skills/audio-minimal/example.html +118 -0
  63. package/skills/audio-orchestral/SKILL.md +156 -0
  64. package/skills/audio-orchestral/example.html +140 -0
  65. package/skills/audio-r-and-b/SKILL.md +134 -0
  66. package/skills/audio-r-and-b/example.html +154 -0
  67. package/skills/audio-r-and-b/example.mp3 +0 -0
  68. package/skills/audio-techno/SKILL.md +140 -0
  69. package/skills/audio-techno/example.html +125 -0
  70. package/skills/audio-trap/SKILL.md +123 -0
  71. package/skills/audio-trap/example.html +119 -0
  72. package/skills/render-retest/example.html +115 -0
  73. package/skills/tuneframes/SKILL.md +221 -0
  74. package/skills/tuneframes-cli/SKILL.md +46 -0
  75. package/skills/verify/example.html +104 -0
  76. package/src/cli.js +260 -151
  77. package/src/render.js +134 -7
  78. package/examples/example-bass.mp3 +0 -0
  79. package/examples/example-demo-beat.wav +0 -0
  80. package/examples/example-orchestral.wav +0 -0
package/src/cli.js CHANGED
@@ -1,152 +1,261 @@
1
- #!/usr/bin/env node
2
- /**
3
- * TuneFrames CLI
4
- *
5
- * Commands:
6
- * tuneframes render <file.html> [--output track.mp3]
7
- * tuneframes init <project-name>
8
- * tuneframes preview <file.html>
9
- * tuneframes validate <file.html>
10
- * tuneframes add <preset-name>
11
- */
12
-
13
- const { render } = require('./render');
14
- const fs = require('fs');
15
- const path = require('path');
16
- const { spawn } = require('child_process');
17
-
18
- const PRESETS = ['drums-lofi', 'reverb-warm', 'chord-progression', 'bass-saw', 'lead-piano'];
19
-
20
- async function main() {
21
- const [,, cmd, ...args] = process.argv;
22
-
23
- switch (cmd) {
24
- case 'render': {
25
- const inputFile = args[0];
26
- const outputArg = args.find(a => a.startsWith('--output='));
27
- const outputFile = outputArg
28
- ? outputArg.split('=')[1]
29
- : inputFile.replace(/\.html$/, '.mp3');
30
-
31
- if (!inputFile) {
32
- console.error('Usage: tuneframes render <file.html> [--output=track.mp3] [--format wav]');
33
- process.exit(1);
34
- }
35
-
36
- if (!fs.existsSync(inputFile)) {
37
- console.error(`File not found: ${inputFile}`);
38
- process.exit(1);
39
- }
40
-
41
- console.log(`Rendering ${inputFile} ${outputFile}`);
42
- const result = await render(inputFile, outputFile);
43
- console.log(`Done. Output: ${result.output}`);
44
- break;
45
- }
46
-
47
- case 'init': {
48
- const [projectName] = args;
49
- if (!projectName) {
50
- console.error('Usage: tuneframes init <project-name>');
51
- process.exit(1);
52
- }
53
-
54
- const dir = path.join(process.cwd(), projectName);
55
- fs.mkdirSync(dir, { recursive: true });
56
-
57
- const example = fs.readFileSync(
58
- path.join(__dirname, '../examples/example-lofi.html'), 'utf8'
59
- );
60
- fs.writeFileSync(path.join(dir, 'composition.html'), example);
61
- fs.writeFileSync(path.join(dir, 'README.md'),
62
- `# ${projectName}\n\nRun: tuneframes render composition.html\n`);
63
-
64
- console.log(`Initialized ${projectName}/`);
65
- break;
66
- }
67
-
68
- case 'preview': {
69
- const inputFile = args[0];
70
- if (!inputFile) {
71
- console.error('Usage: tuneframes preview <file.html>');
72
- process.exit(1);
73
- }
74
- const url = `file://${path.resolve(inputFile)}`;
75
- console.log(`Preview: ${url}`);
76
- if (process.platform === 'darwin') {
77
- spawn('open', [url]);
78
- } else if (process.platform === 'linux') {
79
- spawn('xdg-open', [url]);
80
- }
81
- break;
82
- }
83
-
84
- case 'validate': {
85
- const inputFile = args[0];
86
- if (!inputFile) {
87
- console.error('Usage: tuneframes validate <file.html>');
88
- process.exit(1);
89
- }
90
- if (!fs.existsSync(inputFile)) {
91
- console.error(`File not found: ${inputFile}`);
92
- process.exit(1);
93
- }
94
-
95
- const tmpOut = `/tmp/tuneframes-validate-${Date.now()}.mp3`;
96
- try {
97
- await render(inputFile, tmpOut);
98
- const stats = fs.statSync(tmpOut);
99
- if (stats.size < 5000) {
100
- console.error(`FAIL: ${inputFile} rendered to ${stats.size} bytes (< 5000)`);
101
- process.exit(1);
102
- }
103
- console.log(`OK: ${inputFile} — ${stats.size} bytes`);
104
- } finally {
105
- if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut);
106
- }
107
- break;
108
- }
109
-
110
- case 'add': {
111
- const [presetName] = args;
112
- if (!presetName) {
113
- console.error(`Usage: tuneframes add <preset>\nAvailable: ${PRESETS.join(', ')}`);
114
- process.exit(1);
115
- }
116
- if (!PRESETS.includes(presetName)) {
117
- console.error(`Unknown preset: ${presetName}\nAvailable: ${PRESETS.join(', ')}`);
118
- process.exit(1);
119
- }
120
-
121
- const presetPath = path.join(__dirname, `../registry/presets/${presetName}.html`);
122
- const content = fs.readFileSync(presetPath, 'utf8');
123
-
124
- // Append to composition.html in current directory
125
- const compPath = path.join(process.cwd(), 'composition.html');
126
- if (!fs.existsSync(compPath)) {
127
- console.error('No composition.html found. Run tuneframes init first.');
128
- process.exit(1);
129
- }
130
-
131
- const existing = fs.readFileSync(compPath, 'utf8');
132
- const insertMarker = '</script>';
133
- if (existing.includes(insertMarker)) {
134
- const newContent = existing.replace(insertMarker, content + '\n' + insertMarker);
135
- fs.writeFileSync(compPath, newContent);
136
- } else {
137
- fs.appendFileSync(compPath, '\n' + content);
138
- }
139
-
140
- console.log(`Added ${presetName} to composition.html`);
141
- break;
142
- }
143
-
144
- default:
145
- console.log(`TuneFrames CLI\n\nCommands:\n tuneframes render <file.html> Render composition to MP3\n tuneframes init <name> Initialize new project\n tuneframes preview <file.html> Open in browser\n tuneframes validate <file.html> Validate composition (headless test render)\n tuneframes add <preset> Add preset to composition.html\n\nPresets: ${PRESETS.join(', ')}`);
146
- }
147
- }
148
-
149
- main().catch(err => {
150
- console.error('Error:', err.message);
151
- process.exit(1);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TuneFrames CLI
4
+ *
5
+ * Commands:
6
+ * tuneframes render <file.html> [--output track.mp3]
7
+ * tuneframes init <project-name>
8
+ * tuneframes preview <file.html>
9
+ * tuneframes validate <file.html>
10
+ * tuneframes add <preset-name>
11
+ */
12
+
13
+ const { render } = require('./render');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { spawn } = require('child_process');
17
+
18
+ const PRESETS = ['drums-lofi', 'reverb-warm', 'chord-progression', 'bass-saw', 'lead-piano'];
19
+
20
+ async function main() {
21
+ const [,, cmd, ...args] = process.argv;
22
+
23
+ switch (cmd) {
24
+ case 'render': {
25
+ const inputFile = args[0];
26
+ const equalsArg = args.find(a => a.startsWith('--output='));
27
+ const separateIdx = args.indexOf('--output');
28
+ const formatIdx = args.indexOf('--format');
29
+ const outputFile = equalsArg
30
+ ? equalsArg.split('=')[1]
31
+ : separateIdx !== -1 && args[separateIdx + 1] && !args[separateIdx + 1].startsWith('--')
32
+ ? args[separateIdx + 1]
33
+ : inputFile.replace(/\.html$/, '.mp3');
34
+ const outputFormat = formatIdx !== -1 && args[formatIdx + 1] ? args[formatIdx + 1] : 'mp3';
35
+
36
+ const timeoutEqArg = args.find(a => a.startsWith('--timeout='));
37
+ const timeoutIdx = args.indexOf('--timeout');
38
+ const timeoutSec = timeoutEqArg
39
+ ? parseInt(timeoutEqArg.split('=')[1], 10)
40
+ : timeoutIdx !== -1 && args[timeoutIdx + 1] && !args[timeoutIdx + 1].startsWith('--')
41
+ ? parseInt(args[timeoutIdx + 1], 10)
42
+ : 60;
43
+ const timeoutMs = timeoutSec * 1000;
44
+
45
+ if (!inputFile) {
46
+ console.error('Usage: tuneframes render <file.html> [--output=track.mp3] [--format wav] [--timeout 60]');
47
+ process.exit(1);
48
+ }
49
+
50
+ if (!fs.existsSync(inputFile)) {
51
+ console.error(`File not found: ${inputFile}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ console.log(`Rendering ${inputFile} ${outputFile}`);
56
+ const result = await render(inputFile, outputFile, outputFormat, timeoutMs);
57
+ console.log(`Done. Output: ${result.output}`);
58
+ break;
59
+ }
60
+
61
+ case 'init': {
62
+ const [projectName] = args;
63
+ if (!projectName) {
64
+ console.error('Usage: tuneframes init <project-name>');
65
+ process.exit(1);
66
+ }
67
+
68
+ const dir = path.join(process.cwd(), projectName);
69
+ fs.mkdirSync(dir, { recursive: true });
70
+
71
+ const example = fs.readFileSync(
72
+ path.join(__dirname, '../examples/example-lofi.html'), 'utf8'
73
+ );
74
+ fs.writeFileSync(path.join(dir, 'composition.html'), example);
75
+ fs.writeFileSync(path.join(dir, 'README.md'),
76
+ `# ${projectName}\n\nRun: tuneframes render composition.html\n`);
77
+
78
+ console.log(`Initialized ${projectName}/`);
79
+ break;
80
+ }
81
+
82
+ case 'preview': {
83
+ const inputFile = args[0];
84
+ if (!inputFile) {
85
+ console.error('Usage: tuneframes preview <file.html>');
86
+ process.exit(1);
87
+ }
88
+ const url = `file://${path.resolve(inputFile)}`;
89
+ console.log(`Preview: ${url}`);
90
+ if (process.platform === 'darwin') {
91
+ spawn('open', [url]);
92
+ } else if (process.platform === 'linux') {
93
+ spawn('xdg-open', [url]);
94
+ }
95
+ break;
96
+ }
97
+
98
+ case 'validate': {
99
+ const inputFile = args[0];
100
+ if (!inputFile) {
101
+ console.error('Usage: tuneframes validate <file.html>');
102
+ process.exit(1);
103
+ }
104
+ if (!fs.existsSync(inputFile)) {
105
+ console.error(`File not found: ${inputFile}`);
106
+ process.exit(1);
107
+ }
108
+
109
+ const tmpOut = `/tmp/tuneframes-validate-${Date.now()}.mp3`;
110
+ try {
111
+ await render(inputFile, tmpOut);
112
+ const stats = fs.statSync(tmpOut);
113
+ if (stats.size < 5000) {
114
+ console.error(`FAIL: ${inputFile} rendered to ${stats.size} bytes (< 5000)`);
115
+ process.exit(1);
116
+ }
117
+ console.log(`OK: ${inputFile} ${stats.size} bytes`);
118
+ } finally {
119
+ if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut);
120
+ }
121
+ break;
122
+ }
123
+
124
+ case 'lint': {
125
+ const inputFile = args[0];
126
+ if (!inputFile) {
127
+ console.error('Usage: tuneframes lint <file.html>');
128
+ process.exit(1);
129
+ }
130
+ if (!fs.existsSync(inputFile)) {
131
+ console.error(`File not found: ${inputFile}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ const content = fs.readFileSync(inputFile, 'utf8');
136
+ const errors = [];
137
+
138
+ // Check 1: must have <div id="tuneframes">
139
+ if (!content.includes('id="tuneframes"')) {
140
+ errors.push('Missing <div id="tuneframes"> metadata block');
141
+ }
142
+
143
+ // Check 2: metadata must be valid JSON
144
+ const metaMatch = content.match(/id="tuneframes"[^>]*>([^<]+)<\/div>/);
145
+ if (metaMatch) {
146
+ try {
147
+ JSON.parse(metaMatch[1].trim());
148
+ } catch (e) {
149
+ errors.push(`Invalid JSON in metadata block: ${e.message}`);
150
+ }
151
+ }
152
+
153
+ // Check 3: must load Tone.js
154
+ if (!content.includes('Tone.js') && !content.includes('tone')) {
155
+ errors.push('No Tone.js script reference found');
156
+ }
157
+
158
+ // Check 4: must have async function main()
159
+ if (!content.includes('async function main()')) {
160
+ errors.push('Missing async function main()');
161
+ }
162
+
163
+ // Check 5: main() should contain await Tone.start()
164
+ if (content.includes('async function main()') && !content.includes('Tone.start()')) {
165
+ errors.push('main() should call await Tone.start()');
166
+ }
167
+
168
+ if (errors.length === 0) {
169
+ console.log(`OK: ${inputFile}`);
170
+ } else {
171
+ errors.forEach(e => console.error(`FAIL: ${e}`));
172
+ process.exit(1);
173
+ }
174
+ break;
175
+ }
176
+
177
+ case 'add': {
178
+ const [presetName] = args;
179
+ if (!presetName) {
180
+ console.error(`Usage: tuneframes add <preset>\nAvailable: ${PRESETS.join(', ')}`);
181
+ process.exit(1);
182
+ }
183
+ if (!PRESETS.includes(presetName)) {
184
+ console.error(`Unknown preset: "${presetName}"\nAvailable presets: ${PRESETS.join(', ')}\n\nFor CDN soundfont instruments (Tone.Sampler), run: tuneframes instruments`);
185
+ process.exit(1);
186
+ }
187
+
188
+ const presetPath = path.join(__dirname, `../registry/presets/${presetName}.html`);
189
+ const content = fs.readFileSync(presetPath, 'utf8');
190
+
191
+ // Append to composition.html in current directory
192
+ const compPath = path.join(process.cwd(), 'composition.html');
193
+ if (!fs.existsSync(compPath)) {
194
+ console.error('No composition.html found. Run tuneframes init first.');
195
+ process.exit(1);
196
+ }
197
+
198
+ const existing = fs.readFileSync(compPath, 'utf8');
199
+ const insertMarker = '</script>';
200
+ if (existing.includes(insertMarker)) {
201
+ const newContent = existing.replace(insertMarker, content + '\n' + insertMarker);
202
+ fs.writeFileSync(compPath, newContent);
203
+ } else {
204
+ fs.appendFileSync(compPath, '\n' + content);
205
+ }
206
+
207
+ console.log(`Added ${presetName} to composition.html`);
208
+ break;
209
+ }
210
+
211
+ case 'instruments': {
212
+ const GLEITZ_BASE = 'https://gleitz.github.io/midi-js-soundfonts/MusyngKite/{instrument}-mp3/{note}.mp3';
213
+ const DRUMS_CDN = 'https://dave4mpls.github.io/midi-js-soundfonts-with-drums/FluidR3_GM/drums-mp3/{note}.mp3';
214
+
215
+ console.log('gleitz CDN instruments (MusyngKite, high quality)');
216
+ console.log(`URL pattern: ${GLEITZ_BASE}\n`);
217
+
218
+ const categories = {
219
+ 'Piano': ['acoustic_grand_piano','bright_acoustic_piano','electric_grand_piano','honkytonk_piano','electric_piano_1','electric_piano_2','harpsichord','clavinet'],
220
+ 'Chromatic Perc': ['celesta','glockenspiel','music_box','vibraphone','marimba','xylophone','tubular_bells','dulcimer'],
221
+ 'Organ': ['drawbar_organ','percussive_organ','rock_organ','church_organ','reed_organ','accordion','harmonica','tango_accordion'],
222
+ 'Guitar': ['acoustic_guitar_nylon','acoustic_guitar_steel','electric_guitar_jazz','electric_guitar_clean','electric_guitar_muted','overdriven_guitar','distortion_guitar','guitar_harmonics'],
223
+ 'Bass': ['acoustic_bass','electric_bass_finger','electric_bass_pick','fretless_bass','slap_bass_1','slap_bass_2','synth_bass_1','synth_bass_2'],
224
+ 'Strings': ['violin','viola','cello','contrabass','tremolo_strings','pizzicato_strings','orchestral_harp','timpani'],
225
+ 'Ensemble': ['string_ensemble_1','string_ensemble_2','synth_strings_1','synth_strings_2','choir_aahs','voice_oohs','synth_choir','orchestra_hit'],
226
+ 'Brass': ['trumpet','trombone','tuba','muted_trumpet','french_horn','brass_section','synth_brass_1','synth_brass_2'],
227
+ 'Reed': ['soprano_sax','alto_sax','tenor_sax','baritone_sax','oboe','english_horn','bassoon','clarinet'],
228
+ 'Pipe': ['piccolo','flute','recorder','pan_flute','blown_bottle','shakuhachi','whistle','ocarina'],
229
+ 'Synth Lead': ['lead_1_square','lead_2_sawtooth','lead_3_calliope','lead_4_chiff','lead_5_charang','lead_6_voice','lead_7_fifths','lead_8_bass_and_lead'],
230
+ 'Synth Pad': ['pad_1_new_age','pad_2_warm','pad_3_polysynth','pad_4_choir','pad_5_bowed','pad_6_metallic','pad_7_halo','pad_8_sweep'],
231
+ 'Synth FX': ['fx_1_rain','fx_2_soundtrack','fx_3_crystal','fx_4_atmosphere','fx_5_brightness','fx_6_goblins','fx_7_echoes','fx_8_scifi'],
232
+ 'Ethnic': ['sitar','banjo','shamisen','koto','kalimba','bagpipe','fiddle','shanai'],
233
+ 'Percussive': ['tinkle_bell','agogo','steel_drums','woodblock','taiko_drum','melodic_tom','synth_drum','reverse_cymbal'],
234
+ 'Sound FX': ['guitar_fret_noise','breath_noise','seashore','bird_tweet','telephone_ring','helicopter','applause','gunshot'],
235
+ };
236
+
237
+ for (const [cat, list] of Object.entries(categories)) {
238
+ console.log(`${cat}:`);
239
+ console.log(' ' + list.join(' '));
240
+ }
241
+
242
+ console.log('\nDrums (dave4mpls fork — standard gleitz does not include GM drums):');
243
+ console.log(` URL: ${DRUMS_CDN}`);
244
+ console.log(' Trigger notes: C2=Kick D2=Snare Gb2=Hi-Hat(closed) Bb2=Hi-Hat(open) Db3=Crash Eb3=Ride');
245
+
246
+ console.log('\nNotes:');
247
+ console.log(' - All 88 notes present per instrument (A0–C8), flat notation only (Ab4.mp3, not G#4.mp3)');
248
+ console.log(' - Tone.Sampler accepts both Ab4 and G#4 as url keys — either works');
249
+ console.log(' - Set window.TUNEFRAMES_READY = Tone.loaded() so the renderer waits for samples');
250
+ break;
251
+ }
252
+
253
+ default:
254
+ console.log(`TuneFrames CLI\n\nCommands:\n tuneframes render <file.html> Render composition to MP3\n tuneframes init <name> Initialize new project\n tuneframes preview <file.html> Open in browser\n tuneframes validate <file.html> Validate composition (headless test render)\n tuneframes lint <file.html> Lint composition (static HTML analysis)\n tuneframes add <preset> Add preset to composition.html\n tuneframes instruments List gleitz CDN instruments for Tone.Sampler\n\nPresets: ${PRESETS.join(', ')}`);
255
+ }
256
+ }
257
+
258
+ main().catch(err => {
259
+ console.error('Error:', err.message);
260
+ process.exit(1);
152
261
  });
package/src/render.js CHANGED
@@ -37,6 +37,60 @@ const HELPER_SCRIPT = `
37
37
 
38
38
  window.audioBufferToWav = audioBufferToWav;
39
39
 
40
+ // Tone.js CDN bundle defines exports as non-writable, non-configurable getter
41
+ // properties (rollup live-binding pattern). Direct assignment and Object.defineProperty
42
+ // both fail to override them. Proxy on window.Tone is the only reliable intercept.
43
+ //
44
+ // AudioWorklet effects (Freeverb, BitCrusher, Chebyshev) fail in headless offline
45
+ // rendering because worklet modules cannot load. The Proxy returns unity-gain
46
+ // passthroughs for those classes when inside a Tone.Offline() call (_inOff flag).
47
+ //
48
+ // Reverb tracking is also done in the Proxy get trap (rather than Tone.Reverb = ...)
49
+ // because Tone.Reverb is a non-configurable no-setter accessor — setting it would
50
+ // violate Proxy invariants. Instances pushed to window._tfRev; awaited after main().
51
+ window._patchToneWorklets = function() {
52
+ if (typeof Tone === 'undefined' || window._tonePatched) return;
53
+ window._tonePatched = true;
54
+
55
+ const _orig = window.Tone;
56
+ const _origOffline = _orig.Offline;
57
+
58
+ const _mkPass = () => class extends _orig.Gain {
59
+ constructor() { super(1); }
60
+ // Reverb-compat stubs: some skills call await reverb.generate() or await reverb.ready.
61
+ generate() { return Promise.resolve(this); }
62
+ get ready() { return Promise.resolve(this); }
63
+ };
64
+ // Reverb included: its IR generation calls the module-internal Offline() which
65
+ // creates a nested offline context and corrupts the outer timeline. Passthrough
66
+ // eliminates the nested context entirely. Audio flows through; dry-signal-only
67
+ // is acceptable for a headless render test.
68
+ const _fx = { Freeverb: _mkPass(), BitCrusher: _mkPass(), Chebyshev: _mkPass(), Reverb: _mkPass() };
69
+
70
+ let _inOff = false;
71
+
72
+ const _wOffline = async function(cb, ...args) {
73
+ _inOff = true;
74
+ try { return await _origOffline(cb, ...args); }
75
+ finally { _inOff = false; }
76
+ };
77
+
78
+ window.Tone = new Proxy(_orig, {
79
+ get(t, p) {
80
+ if (p === 'Offline') return _wOffline;
81
+ if (_inOff && p in _fx) return _fx[p];
82
+ return t[p];
83
+ },
84
+ set(t, p, v) {
85
+ // Return false for non-configurable no-setter accessors to honor Proxy invariant.
86
+ const d = Object.getOwnPropertyDescriptor(t, p);
87
+ if (d && !d.configurable && d.get && !d.set) return false;
88
+ try { t[p] = v; } catch(e) {}
89
+ return true;
90
+ }
91
+ });
92
+ };
93
+
40
94
  window.renderComposition = async function(wavPath) {
41
95
  // Wait for Tone.js to be ready
42
96
  let attempts = 0;
@@ -47,21 +101,79 @@ const HELPER_SCRIPT = `
47
101
  if (typeof Tone === 'undefined') throw new Error('Tone not loaded');
48
102
  if (typeof window.audioBufferToWav !== 'function') throw new Error('audioBufferToWav not injected');
49
103
 
50
- let bpm = 120, duration = '4n';
104
+ window._patchToneWorklets();
105
+
106
+ // Wait for TUNEFRAMES_READY if composition uses Tone.Sampler / CDN samples
107
+ if ('TUNEFRAMES_READY' in window) {
108
+ const signal = window.TUNEFRAMES_READY;
109
+ if (signal && typeof signal.then === 'function') {
110
+ await signal;
111
+ } else {
112
+ let waited = 0;
113
+ while (!window.TUNEFRAMES_READY && waited < 120000) {
114
+ await new Promise(r => setTimeout(r, 200));
115
+ waited += 200;
116
+ }
117
+ if (!window.TUNEFRAMES_READY) throw new Error('TUNEFRAMES_READY timed out after 120s');
118
+ }
119
+ }
120
+
121
+ let bpm = 120, duration = '12s';
51
122
  const metaEl = document.getElementById('tuneframes');
52
123
  if (metaEl) {
124
+ // Support both JSON textContent and data-* attributes
53
125
  try {
54
- const meta = JSON.parse(metaEl.textContent);
55
- bpm = meta.bpm || 120;
56
- duration = meta.duration || '4n';
126
+ const txt = metaEl.textContent.trim();
127
+ if (txt) {
128
+ const meta = JSON.parse(txt);
129
+ bpm = meta.bpm || 120;
130
+ duration = meta.duration || '12s';
131
+ }
57
132
  } catch(e) {}
133
+ if (metaEl.dataset.bpm) bpm = parseInt(metaEl.dataset.bpm, 10) || bpm;
134
+ if (metaEl.dataset.duration) duration = metaEl.dataset.duration || duration;
58
135
  }
59
136
 
60
137
  const durationSec = Math.max(Tone.Time(duration).toSeconds() + 0.5, 2);
61
138
  console.log('TuneFrames: rendering', durationSec, 's at BPM', bpm);
62
139
 
63
140
  const audioBuffer = await Tone.Offline(async () => {
141
+ // Fix: MetalSynth.triggerAttackRelease calls triggerAttack(computedTime, velocity)
142
+ // but triggerAttack(note, time, velocity) treats first arg as note and second as time,
143
+ // so toSeconds(velocity=undefined) = 0 for every trigger. Replace TAR to call
144
+ // _triggerEnvelopeAttack/_triggerEnvelopeRelease directly with the correct time.
145
+ // Also bumps same-time triggers by 0.1ms to prevent StateTimeline ordering errors.
146
+ if (typeof Tone !== 'undefined' && Tone.MetalSynth) {
147
+ Tone.MetalSynth.prototype.triggerAttackRelease = function(duration, time, velocity) {
148
+ const rawT = (time === undefined || time === null || +time <= 0) ? 0.05 : +time;
149
+ const vel = velocity !== undefined ? velocity : 1;
150
+ const dur = Tone.Time(duration).toSeconds();
151
+ let safeT = rawT;
152
+ try {
153
+ if (this._oscillators && this._oscillators[0] && this._oscillators[0]._state) {
154
+ const tl = this._oscillators[0]._state._timeline;
155
+ if (tl && tl.length > 0 && safeT <= tl[tl.length - 1].time) {
156
+ safeT = tl[tl.length - 1].time + 0.0001;
157
+ }
158
+ }
159
+ } catch(e) {}
160
+ this._triggerEnvelopeAttack(safeT, vel);
161
+ // Release envelope only; stopping oscillators adds StateTimeline stop events
162
+ // that block same-period re-triggers on rapid percussion patterns.
163
+ if (this.envelope && typeof this.envelope.triggerRelease === 'function') {
164
+ this.envelope.triggerRelease(safeT + dur);
165
+ }
166
+ return this;
167
+ };
168
+ }
169
+
64
170
  if (typeof main === 'function') await main();
171
+
172
+ // Auto-start Transport so Tone.Sequence / Tone.Part patterns play.
173
+ // Skills that already call Transport.start() inside main() are unaffected.
174
+ if (typeof Tone !== 'undefined' && Tone.Transport && Tone.Transport.state !== 'started') {
175
+ Tone.Transport.start(0);
176
+ }
65
177
  }, durationSec, 1, 44100, bpm);
66
178
 
67
179
  console.log('TuneFrames: buffer ready', audioBuffer.duration, audioBuffer.length);
@@ -76,10 +188,12 @@ const HELPER_SCRIPT = `
76
188
  })();
77
189
  `;
78
190
 
79
- async function render(compositionPath, outputPath, format = 'mp3') {
191
+ async function render(compositionPath, outputPath, format = 'mp3', timeout = 60000) {
80
192
  const browser = await chromium.launch({ headless: true });
81
193
  const page = await browser.newPage();
82
194
 
195
+ page.setDefaultTimeout(timeout + 60000);
196
+
83
197
  page.on('pageerror', err => console.error('BROWSER ERR:', err.message));
84
198
  page.on('console', msg => console.log('BROWSER:', msg.type(), msg.text()));
85
199
 
@@ -93,15 +207,28 @@ async function render(compositionPath, outputPath, format = 'mp3') {
93
207
  await page.addInitScript({ content: HELPER_SCRIPT });
94
208
 
95
209
  await page.goto(`file://${path.resolve(compositionPath)}`, {
96
- waitUntil: 'domcontentloaded', timeout: 30000
210
+ waitUntil: 'domcontentloaded', timeout
97
211
  });
98
212
 
99
213
  // Wait for Tone.js to actually be available
100
214
  await page.waitForFunction(() => typeof Tone !== 'undefined', { timeout: 15000 });
101
-
215
+
216
+ // Apply global patches before any renderComposition runs.
217
+ // This covers skills that define their own renderComposition (e.g. audio-downtempo).
218
+ // Apply global patches before any renderComposition runs.
219
+ await page.evaluate(() => {
220
+ if (typeof window._patchToneWorklets === 'function') window._patchToneWorklets();
221
+ });
222
+
102
223
  // Also wait for our renderComposition to be defined
103
224
  await page.waitForFunction(() => typeof window.renderComposition === 'function', { timeout: 15000 });
104
225
 
226
+ // Check for TUNEFRAMES_READY signal (indicates Tone.Sampler / CDN sample usage)
227
+ const needsSampleWait = await page.evaluate(() => 'TUNEFRAMES_READY' in window);
228
+ if (needsSampleWait) {
229
+ console.log('Waiting for samples to load...');
230
+ }
231
+
105
232
  const wavPath = path.resolve(outputPath.replace(/\.[^.]+$/, '.wav'));
106
233
 
107
234
  console.log('Starting render...');
Binary file
Binary file
Binary file