@moood/render 0.0.0 → 0.1.3

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/engine/music.js CHANGED
@@ -1,279 +1,279 @@
1
- // A world's music: live instrumental from Lyria RealTime (Gemini API), steered by weighted
2
- // text descriptions. The artist writes many (most at weight 0); the author tunes them with
3
- // sliders, adds their own lines, removes the artist's, and saves — a new version of the scene.
4
- // The stream starts at the first touch of the page (browsers don't let sound start by itself)
5
- // and follows the open world; changes are sent live while it plays.
6
- // It comes through our server (music.mjs), so the Gemini key never reaches the browser:
7
- // GET /api/music/stream — frames [type:1][length:4 LE][payload], 1 = PCM16 48 kHz stereo,
8
- // 2 = a JSON event; POST /api/music/control — prompts, settings, play / pause / reset.
9
- // Weight 0 means "not now" and is never sent (the API refuses zero weights).
10
- const MooodMusic = (() => {
11
- const RATE = 48000, LEAD = 0.25; // seconds of audio queued ahead
12
- const store = {
13
- get: k => { try { return localStorage.getItem(k); } catch { return null; } },
14
- set: (k, v) => { try { localStorage.setItem(k, v); } catch {} },
15
- };
16
- let ui = null, onSave = null;
17
- let music = null, savedJson = ''; // what the panel shows / what the scene has
18
- let sid = null, opening = null, ctx = null, out = null, nextT = 0;
19
- let playing = false, starting = false, sentBpm = null, pushTimer = null;
20
- let wanted = store.get('moood:music') !== 'off';
21
- MooodVolume.on((kind, v) => { if (kind === 'music' && playing && out) out.gain.setTargetAtTime(v, ctx.currentTime, 0.1); });
22
-
23
- const clone = m => JSON.parse(JSON.stringify(m || { prompts: [] }));
24
- const active = () => (music?.prompts || []).filter(p => p.text.trim() && p.weight > 0);
25
-
26
- function status(text) { if (ui) ui.status.textContent = text; }
27
-
28
- // ── the stream ───────────────────────────────────────────────────────────
29
- function open() {
30
- if (sid) return Promise.resolve();
31
- if (opening) return opening;
32
- opening = new Promise((resolve, reject) => {
33
- const id = 's-' + Math.random().toString(36).slice(2, 12);
34
- status('подключаюсь…');
35
- fetch(`/api/music/stream?sid=${id}`).then(async r => {
36
- if (!r.ok || !r.body) throw new Error('поток музыки не открылся');
37
- const reader = r.body.getReader();
38
- let buf = new Uint8Array(0);
39
- for (;;) {
40
- const { value, done } = await reader.read();
41
- if (done) break;
42
- const joined = new Uint8Array(buf.length + value.length);
43
- joined.set(buf); joined.set(value, buf.length);
44
- buf = joined;
45
- while (buf.length >= 5) {
46
- const len = buf[1] | (buf[2] << 8) | (buf[3] << 16) | (buf[4] << 24);
47
- if (buf.length < 5 + len) break;
48
- const type = buf[0], payload = buf.slice(5, 5 + len);
49
- buf = buf.slice(5 + len);
50
- if (type === 1) enqueue(payload);
51
- else {
52
- const ev = JSON.parse(new TextDecoder().decode(payload));
53
- if (ev.ready) { sid = id; resolve(); }
54
- if (ev.error) { status('ошибка: ' + ev.error); reject(new Error(ev.error)); }
55
- if (ev.filtered) status(`не принят: «${ev.filtered}»`);
56
- }
57
- }
58
- }
59
- throw new Error('поток закрыт');
60
- }).catch(e => {
61
- const was = sid === id;
62
- if (sid === id) sid = null;
63
- sentBpm = null;
64
- reject(e);
65
- if (was && playing) { playing = false; paint(); status(e.message); }
66
- });
67
- });
68
- return opening.finally(() => { opening = null; });
69
- }
70
-
71
- async function control(body) {
72
- if (!sid) return;
73
- const r = await fetch('/api/music/control', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sid, ...body }) });
74
- if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'управление не прошло');
75
- }
76
-
77
- function enqueue(bytes) {
78
- window.__mooodMusicChunks = (window.__mooodMusicChunks || 0) + 1; // for checking the stream
79
- if (!ctx || !playing) return;
80
- const n = bytes.length >> 2, buf = ctx.createBuffer(2, n, RATE);
81
- const L = buf.getChannelData(0), R = buf.getChannelData(1);
82
- const pcm = new DataView(bytes.buffer, bytes.byteOffset, n * 4);
83
- for (let i = 0; i < n; i++) {
84
- L[i] = pcm.getInt16(i * 4, true) / 32768;
85
- R[i] = pcm.getInt16(i * 4 + 2, true) / 32768;
86
- }
87
- const src = ctx.createBufferSource();
88
- src.buffer = buf;
89
- src.connect(out);
90
- const at = Math.max(nextT, ctx.currentTime + LEAD);
91
- src.start(at);
92
- nextT = at + buf.duration;
93
- }
94
-
95
- // send what the panel shows: the prompts with weight, and the settings
96
- async function push() {
97
- if (!sid || !playing) return;
98
- const prompts = active();
99
- if (!prompts.length) { status('все веса на нуле — тишина'); await control({ action: 'pause' }); return; }
100
- const config = {};
101
- for (const k of ['density', 'brightness']) if (typeof music[k] === 'number') config[k] = music[k];
102
- if (typeof music.bpm === 'number') config.bpm = Math.round(music.bpm);
103
- const reset = config.bpm !== sentBpm && sentBpm !== null; // tempo changes need a fresh start
104
- sentBpm = config.bpm ?? null;
105
- await control({ prompts: prompts.map(p => ({ text: p.text.trim(), weight: p.weight })), config });
106
- if (reset) await control({ action: 'reset' });
107
- await control({ action: 'play' });
108
- status('играет');
109
- }
110
- function pushSoon() { clearTimeout(pushTimer); pushTimer = setTimeout(() => push().catch(e => status('ошибка: ' + e.message)), 200); }
111
-
112
- async function start() {
113
- if (playing || starting) return;
114
- starting = true;
115
- paint();
116
- try {
117
- ctx = ctx || new AudioContext({ sampleRate: RATE });
118
- await ctx.resume();
119
- if (!out) { out = ctx.createGain(); out.connect(ctx.destination); }
120
- out.gain.setTargetAtTime(MooodVolume.get('music'), ctx.currentTime, 0.3);
121
- await open();
122
- if (!wanted) return; // switched off while connecting
123
- playing = true;
124
- paint();
125
- await push();
126
- } catch (e) { playing = false; status(e.message); } finally { starting = false; paint(); }
127
- }
128
- async function stop() {
129
- playing = false;
130
- paint();
131
- status('пауза');
132
- if (out && ctx) out.gain.setTargetAtTime(0, ctx.currentTime, 0.25);
133
- nextT = 0;
134
- try { await control({ action: 'pause' }); } catch {}
135
- }
136
-
137
- // ── the panel ────────────────────────────────────────────────────────────
138
- function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
139
-
140
- function build() {
141
- const root = el('section');
142
- root.id = 'music';
143
- root.hidden = true;
144
- const head = el('div', 'music-head');
145
- const play = el('button', 'play');
146
- play.type = 'button';
147
- play.onclick = () => { wanted = !(playing || starting); store.set('moood:music', wanted ? 'on' : 'off'); wanted ? start() : stop(); };
148
- const title = el('span', 'title', 'Музыка');
149
- const close = el('button', 'close', '×');
150
- close.type = 'button';
151
- close.title = 'закрыть';
152
- close.onclick = () => { root.hidden = true; };
153
- head.append(play, title, close);
154
- const st = el('div', 'status');
155
- const list = el('ol', 'prompts');
156
- const add = el('form', 'add');
157
- const addText = el('input');
158
- addText.placeholder = 'своё описание музыки…';
159
- const addBtn = el('button', '', '+');
160
- add.append(addText, addBtn);
161
- add.onsubmit = e => {
162
- e.preventDefault();
163
- const text = addText.value.trim();
164
- if (!text) return;
165
- music.prompts.push({ text, weight: 1 });
166
- addText.value = '';
167
- renderList(); changed();
168
- };
169
- const knobs = el('div', 'knobs');
170
- const save = el('button', 'save', 'сохранить');
171
- save.type = 'button';
172
- save.onclick = async () => {
173
- if (!onSave) return;
174
- save.disabled = true;
175
- status('сохраняю…');
176
- try { await onSave(clone(music)); savedJson = JSON.stringify(music); status(playing ? 'играет' : 'сохранено'); } catch (e) { status('ошибка: ' + e.message); }
177
- paint();
178
- };
179
- const revert = el('button', 'revert', 'как было');
180
- revert.type = 'button';
181
- revert.onclick = () => { music = JSON.parse(savedJson); renderList(); renderKnobs(); changed(); };
182
- const foot = el('div', 'foot');
183
- foot.append(revert, save);
184
- root.append(head, st, list, add, knobs, foot);
185
- document.body.append(root);
186
- return { root, play, status: st, list, knobs, save, revert };
187
- }
188
-
189
- function slider(min, max, step, value, onInput) {
190
- const r = el('input');
191
- r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;
192
- r.oninput = () => onInput(Number(r.value));
193
- return r;
194
- }
195
-
196
- function renderList() {
197
- ui.list.innerHTML = '';
198
- for (const p of music.prompts) {
199
- const li = el('li', p.weight > 0 ? 'on' : '');
200
- const text = el('textarea');
201
- text.rows = 1;
202
- text.value = p.text;
203
- text.oninput = () => { p.text = text.value; changed(); };
204
- const val = el('span', 'val', p.weight.toFixed(2));
205
- const w = slider(0, 2, 0.05, p.weight, v => { p.weight = v; val.textContent = v.toFixed(2); li.classList.toggle('on', v > 0); changed(); });
206
- const del = el('button', 'del', '×');
207
- del.type = 'button';
208
- del.title = 'убрать';
209
- del.onclick = () => { music.prompts.splice(music.prompts.indexOf(p), 1); renderList(); changed(); };
210
- const row = el('div', 'row');
211
- row.append(w, val, del);
212
- li.append(text, row);
213
- ui.list.append(li);
214
- }
215
- }
216
-
217
- function renderKnobs() {
218
- ui.knobs.innerHTML = '';
219
- for (const [k, label, min, max, step, def] of [['density', 'плотность', 0, 1, 0.01, 0.5], ['brightness', 'яркость', 0, 1, 0.01, 0.5], ['bpm', 'темп', 60, 200, 1, 90]]) {
220
- const set = typeof music[k] === 'number';
221
- const row = el('label', set ? '' : 'auto');
222
- const val = el('span', 'val', set ? String(music[k]) : 'авто');
223
- const r = slider(min, max, step, set ? music[k] : def, v => { music[k] = v; val.textContent = String(v); row.classList.remove('auto'); changed(); });
224
- const reset = el('button', 'del', '↺');
225
- reset.type = 'button';
226
- reset.title = 'авто';
227
- reset.onclick = () => { delete music[k]; renderKnobs(); changed(); };
228
- row.append(el('span', 'name', label), r, val, reset);
229
- ui.knobs.append(row);
230
- }
231
- }
232
-
233
- function paint() {
234
- if (!ui) return;
235
- const on = playing || starting;
236
- ui.play.textContent = on ? '⏸' : '▶';
237
- ui.play.title = on ? 'пауза' : 'играть';
238
- const dirty = JSON.stringify(music) !== savedJson;
239
- ui.save.disabled = !dirty || !onSave;
240
- ui.revert.disabled = !dirty;
241
- }
242
-
243
- function changed() { paint(); pushSoon(); }
244
-
245
- // ── the page's side ──────────────────────────────────────────────────────
246
- // init: once; toggle: a button that opens the panel (or the page calls panel()); save(music) →
247
- // persists it (author)
248
- function init({ toggle, save }) {
249
- ui = build();
250
- onSave = save;
251
- if (toggle) toggle.onclick = panel;
252
- // sound may only start from a gesture: the first touch of the page starts it
253
- const first = () => { removeEventListener('pointerdown', first, true); removeEventListener('keydown', first, true); if (wanted && music && active().length) start(); };
254
- addEventListener('pointerdown', first, true);
255
- addEventListener('keydown', first, true);
256
- status(wanted ? 'заиграет от первого касания' : 'пауза');
257
- paint();
258
- }
259
-
260
- // the open world's music (from its spec), or none
261
- function show(m) {
262
- music = clone(m);
263
- music.prompts = (music.prompts || []).map(p => ({ text: String(p.text || ''), weight: Number(p.weight) || 0 }));
264
- savedJson = JSON.stringify(music);
265
- if (!ui) return;
266
- renderList();
267
- renderKnobs();
268
- paint();
269
- if (playing) pushSoon();
270
- }
271
-
272
- // author or player: players hear, only authors tune
273
- function setEditable(on) { if (ui) ui.root.classList.toggle('readonly', !on); }
274
-
275
- // opens or closes the panel
276
- function panel() { ui.root.hidden = !ui.root.hidden; }
277
-
278
- return { init, show, setEditable, panel };
279
- })();
1
+ // A world's music: live instrumental from Lyria RealTime (Gemini API), steered by weighted
2
+ // text descriptions. The artist writes many (most at weight 0); the author tunes them with
3
+ // sliders, adds their own lines, removes the artist's, and saves — a new version of the scene.
4
+ // The stream starts at the first touch of the page (browsers don't let sound start by itself)
5
+ // and follows the open world; changes are sent live while it plays.
6
+ // It comes through our server (music.mjs), so the Gemini key never reaches the browser:
7
+ // GET /api/music/stream — frames [type:1][length:4 LE][payload], 1 = PCM16 48 kHz stereo,
8
+ // 2 = a JSON event; POST /api/music/control — prompts, settings, play / pause / reset.
9
+ // Weight 0 means "not now" and is never sent (the API refuses zero weights).
10
+ const MooodMusic = (() => {
11
+ const RATE = 48000, LEAD = 0.25; // seconds of audio queued ahead
12
+ const store = {
13
+ get: k => { try { return localStorage.getItem(k); } catch { return null; } },
14
+ set: (k, v) => { try { localStorage.setItem(k, v); } catch {} },
15
+ };
16
+ let ui = null, onSave = null;
17
+ let music = null, savedJson = ''; // what the panel shows / what the scene has
18
+ let sid = null, opening = null, ctx = null, out = null, nextT = 0;
19
+ let playing = false, starting = false, sentBpm = null, pushTimer = null;
20
+ let wanted = store.get('moood:music') !== 'off';
21
+ MooodVolume.on((kind, v) => { if (kind === 'music' && playing && out) out.gain.setTargetAtTime(v, ctx.currentTime, 0.1); });
22
+
23
+ const clone = m => JSON.parse(JSON.stringify(m || { prompts: [] }));
24
+ const active = () => (music?.prompts || []).filter(p => p.text.trim() && p.weight > 0);
25
+
26
+ function status(text) { if (ui) ui.status.textContent = text; }
27
+
28
+ // ── the stream ───────────────────────────────────────────────────────────
29
+ function open() {
30
+ if (sid) return Promise.resolve();
31
+ if (opening) return opening;
32
+ opening = new Promise((resolve, reject) => {
33
+ const id = 's-' + Math.random().toString(36).slice(2, 12);
34
+ status('подключаюсь…');
35
+ fetch(`/api/music/stream?sid=${id}`).then(async r => {
36
+ if (!r.ok || !r.body) throw new Error('поток музыки не открылся');
37
+ const reader = r.body.getReader();
38
+ let buf = new Uint8Array(0);
39
+ for (;;) {
40
+ const { value, done } = await reader.read();
41
+ if (done) break;
42
+ const joined = new Uint8Array(buf.length + value.length);
43
+ joined.set(buf); joined.set(value, buf.length);
44
+ buf = joined;
45
+ while (buf.length >= 5) {
46
+ const len = buf[1] | (buf[2] << 8) | (buf[3] << 16) | (buf[4] << 24);
47
+ if (buf.length < 5 + len) break;
48
+ const type = buf[0], payload = buf.slice(5, 5 + len);
49
+ buf = buf.slice(5 + len);
50
+ if (type === 1) enqueue(payload);
51
+ else {
52
+ const ev = JSON.parse(new TextDecoder().decode(payload));
53
+ if (ev.ready) { sid = id; resolve(); }
54
+ if (ev.error) { status('ошибка: ' + ev.error); reject(new Error(ev.error)); }
55
+ if (ev.filtered) status(`не принят: «${ev.filtered}»`);
56
+ }
57
+ }
58
+ }
59
+ throw new Error('поток закрыт');
60
+ }).catch(e => {
61
+ const was = sid === id;
62
+ if (sid === id) sid = null;
63
+ sentBpm = null;
64
+ reject(e);
65
+ if (was && playing) { playing = false; paint(); status(e.message); }
66
+ });
67
+ });
68
+ return opening.finally(() => { opening = null; });
69
+ }
70
+
71
+ async function control(body) {
72
+ if (!sid) return;
73
+ const r = await fetch('/api/music/control', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sid, ...body }) });
74
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'управление не прошло');
75
+ }
76
+
77
+ function enqueue(bytes) {
78
+ window.__mooodMusicChunks = (window.__mooodMusicChunks || 0) + 1; // for checking the stream
79
+ if (!ctx || !playing) return;
80
+ const n = bytes.length >> 2, buf = ctx.createBuffer(2, n, RATE);
81
+ const L = buf.getChannelData(0), R = buf.getChannelData(1);
82
+ const pcm = new DataView(bytes.buffer, bytes.byteOffset, n * 4);
83
+ for (let i = 0; i < n; i++) {
84
+ L[i] = pcm.getInt16(i * 4, true) / 32768;
85
+ R[i] = pcm.getInt16(i * 4 + 2, true) / 32768;
86
+ }
87
+ const src = ctx.createBufferSource();
88
+ src.buffer = buf;
89
+ src.connect(out);
90
+ const at = Math.max(nextT, ctx.currentTime + LEAD);
91
+ src.start(at);
92
+ nextT = at + buf.duration;
93
+ }
94
+
95
+ // send what the panel shows: the prompts with weight, and the settings
96
+ async function push() {
97
+ if (!sid || !playing) return;
98
+ const prompts = active();
99
+ if (!prompts.length) { status('все веса на нуле — тишина'); await control({ action: 'pause' }); return; }
100
+ const config = {};
101
+ for (const k of ['density', 'brightness']) if (typeof music[k] === 'number') config[k] = music[k];
102
+ if (typeof music.bpm === 'number') config.bpm = Math.round(music.bpm);
103
+ const reset = config.bpm !== sentBpm && sentBpm !== null; // tempo changes need a fresh start
104
+ sentBpm = config.bpm ?? null;
105
+ await control({ prompts: prompts.map(p => ({ text: p.text.trim(), weight: p.weight })), config });
106
+ if (reset) await control({ action: 'reset' });
107
+ await control({ action: 'play' });
108
+ status('играет');
109
+ }
110
+ function pushSoon() { clearTimeout(pushTimer); pushTimer = setTimeout(() => push().catch(e => status('ошибка: ' + e.message)), 200); }
111
+
112
+ async function start() {
113
+ if (playing || starting) return;
114
+ starting = true;
115
+ paint();
116
+ try {
117
+ ctx = ctx || new AudioContext({ sampleRate: RATE });
118
+ await ctx.resume();
119
+ if (!out) { out = ctx.createGain(); out.connect(ctx.destination); }
120
+ out.gain.setTargetAtTime(MooodVolume.get('music'), ctx.currentTime, 0.3);
121
+ await open();
122
+ if (!wanted) return; // switched off while connecting
123
+ playing = true;
124
+ paint();
125
+ await push();
126
+ } catch (e) { playing = false; status(e.message); } finally { starting = false; paint(); }
127
+ }
128
+ async function stop() {
129
+ playing = false;
130
+ paint();
131
+ status('пауза');
132
+ if (out && ctx) out.gain.setTargetAtTime(0, ctx.currentTime, 0.25);
133
+ nextT = 0;
134
+ try { await control({ action: 'pause' }); } catch {}
135
+ }
136
+
137
+ // ── the panel ────────────────────────────────────────────────────────────
138
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
139
+
140
+ function build() {
141
+ const root = el('section');
142
+ root.id = 'music';
143
+ root.hidden = true;
144
+ const head = el('div', 'music-head');
145
+ const play = el('button', 'play');
146
+ play.type = 'button';
147
+ play.onclick = () => { wanted = !(playing || starting); store.set('moood:music', wanted ? 'on' : 'off'); wanted ? start() : stop(); };
148
+ const title = el('span', 'title', 'Музыка');
149
+ const close = el('button', 'close', '×');
150
+ close.type = 'button';
151
+ close.title = 'закрыть';
152
+ close.onclick = () => { root.hidden = true; };
153
+ head.append(play, title, close);
154
+ const st = el('div', 'status');
155
+ const list = el('ol', 'prompts');
156
+ const add = el('form', 'add');
157
+ const addText = el('input');
158
+ addText.placeholder = 'своё описание музыки…';
159
+ const addBtn = el('button', '', '+');
160
+ add.append(addText, addBtn);
161
+ add.onsubmit = e => {
162
+ e.preventDefault();
163
+ const text = addText.value.trim();
164
+ if (!text) return;
165
+ music.prompts.push({ text, weight: 1 });
166
+ addText.value = '';
167
+ renderList(); changed();
168
+ };
169
+ const knobs = el('div', 'knobs');
170
+ const save = el('button', 'save', 'сохранить');
171
+ save.type = 'button';
172
+ save.onclick = async () => {
173
+ if (!onSave) return;
174
+ save.disabled = true;
175
+ status('сохраняю…');
176
+ try { await onSave(clone(music)); savedJson = JSON.stringify(music); status(playing ? 'играет' : 'сохранено'); } catch (e) { status('ошибка: ' + e.message); }
177
+ paint();
178
+ };
179
+ const revert = el('button', 'revert', 'как было');
180
+ revert.type = 'button';
181
+ revert.onclick = () => { music = JSON.parse(savedJson); renderList(); renderKnobs(); changed(); };
182
+ const foot = el('div', 'foot');
183
+ foot.append(revert, save);
184
+ root.append(head, st, list, add, knobs, foot);
185
+ document.body.append(root);
186
+ return { root, play, status: st, list, knobs, save, revert };
187
+ }
188
+
189
+ function slider(min, max, step, value, onInput) {
190
+ const r = el('input');
191
+ r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;
192
+ r.oninput = () => onInput(Number(r.value));
193
+ return r;
194
+ }
195
+
196
+ function renderList() {
197
+ ui.list.innerHTML = '';
198
+ for (const p of music.prompts) {
199
+ const li = el('li', p.weight > 0 ? 'on' : '');
200
+ const text = el('textarea');
201
+ text.rows = 1;
202
+ text.value = p.text;
203
+ text.oninput = () => { p.text = text.value; changed(); };
204
+ const val = el('span', 'val', p.weight.toFixed(2));
205
+ const w = slider(0, 2, 0.05, p.weight, v => { p.weight = v; val.textContent = v.toFixed(2); li.classList.toggle('on', v > 0); changed(); });
206
+ const del = el('button', 'del', '×');
207
+ del.type = 'button';
208
+ del.title = 'убрать';
209
+ del.onclick = () => { music.prompts.splice(music.prompts.indexOf(p), 1); renderList(); changed(); };
210
+ const row = el('div', 'row');
211
+ row.append(w, val, del);
212
+ li.append(text, row);
213
+ ui.list.append(li);
214
+ }
215
+ }
216
+
217
+ function renderKnobs() {
218
+ ui.knobs.innerHTML = '';
219
+ for (const [k, label, min, max, step, def] of [['density', 'плотность', 0, 1, 0.01, 0.5], ['brightness', 'яркость', 0, 1, 0.01, 0.5], ['bpm', 'темп', 60, 200, 1, 90]]) {
220
+ const set = typeof music[k] === 'number';
221
+ const row = el('label', set ? '' : 'auto');
222
+ const val = el('span', 'val', set ? String(music[k]) : 'авто');
223
+ const r = slider(min, max, step, set ? music[k] : def, v => { music[k] = v; val.textContent = String(v); row.classList.remove('auto'); changed(); });
224
+ const reset = el('button', 'del', '↺');
225
+ reset.type = 'button';
226
+ reset.title = 'авто';
227
+ reset.onclick = () => { delete music[k]; renderKnobs(); changed(); };
228
+ row.append(el('span', 'name', label), r, val, reset);
229
+ ui.knobs.append(row);
230
+ }
231
+ }
232
+
233
+ function paint() {
234
+ if (!ui) return;
235
+ const on = playing || starting;
236
+ ui.play.textContent = on ? '⏸' : '▶';
237
+ ui.play.title = on ? 'пауза' : 'играть';
238
+ const dirty = JSON.stringify(music) !== savedJson;
239
+ ui.save.disabled = !dirty || !onSave;
240
+ ui.revert.disabled = !dirty;
241
+ }
242
+
243
+ function changed() { paint(); pushSoon(); }
244
+
245
+ // ── the page's side ──────────────────────────────────────────────────────
246
+ // init: once; toggle: a button that opens the panel (or the page calls panel()); save(music) →
247
+ // persists it (author)
248
+ function init({ toggle, save }) {
249
+ ui = build();
250
+ onSave = save;
251
+ if (toggle) toggle.onclick = panel;
252
+ // sound may only start from a gesture: the first touch of the page starts it
253
+ const first = () => { removeEventListener('pointerdown', first, true); removeEventListener('keydown', first, true); if (wanted && music && active().length) start(); };
254
+ addEventListener('pointerdown', first, true);
255
+ addEventListener('keydown', first, true);
256
+ status(wanted ? 'заиграет от первого касания' : 'пауза');
257
+ paint();
258
+ }
259
+
260
+ // the open world's music (from its spec), or none
261
+ function show(m) {
262
+ music = clone(m);
263
+ music.prompts = (music.prompts || []).map(p => ({ text: String(p.text || ''), weight: Number(p.weight) || 0 }));
264
+ savedJson = JSON.stringify(music);
265
+ if (!ui) return;
266
+ renderList();
267
+ renderKnobs();
268
+ paint();
269
+ if (playing) pushSoon();
270
+ }
271
+
272
+ // author or player: players hear, only authors tune
273
+ function setEditable(on) { if (ui) ui.root.classList.toggle('readonly', !on); }
274
+
275
+ // opens or closes the panel
276
+ function panel() { ui.root.hidden = !ui.root.hidden; }
277
+
278
+ return { init, show, setEditable, panel };
279
+ })();