nio-cat 0.9.7 → 0.9.9

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.
@@ -0,0 +1,435 @@
1
+ // nio · Trucada amb en Nio. Una conversa de veu en temps real (ElevenLabs Agents) que coneix l'estat
2
+ // de l'ordinador: quins agents hi ha, com van les sessions, què espera permís. També hi pots encarregar feina.
3
+ // L'ordinador dona un URL signat (la clau no surt mai d'allà); el navegador hi parla directament per WebSocket:
4
+ // micròfon → PCM 16 bits → ElevenLabs → àudio PCM de tornada, que es reprodueix a l'instant i es talla si l'interromps.
5
+
6
+ const I = {
7
+ call: '<path d="M6.6 3.5h2.6l1.4 4-2 1.3a12 12 0 0 0 6.6 6.6l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A17 17 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2z"/>',
8
+ hang: '<path d="M3.2 14.6c-.6-.6-.6-1.6.1-2.2C5.8 10 8.8 8.8 12 8.8s6.2 1.2 8.7 3.6c.7.6.7 1.6.1 2.2l-1.6 1.6c-.5.5-1.4.6-2 .1l-2-1.6a1.5 1.5 0 0 1-.5-1.4l.3-1.6a10 10 0 0 0-6 0l.3 1.6c.1.5-.1 1-.5 1.4l-2 1.6c-.6.5-1.5.4-2-.1z"/>',
9
+ mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
10
+ micOff: '<path d="M9 9v2a3 3 0 0 0 5.1 2.1M15 10V6a3 3 0 0 0-5.7-1.3"/><path d="M5 11a7 7 0 0 0 11.5 5.4M19 11a7 7 0 0 1-.6 2.8M12 18v3M4 4l16 16"/>',
11
+ close: '<path d="M6 6l12 12M18 6L6 18"/>',
12
+ };
13
+ const TOOL_LABEL = { estat: 'Mirant com va tot…', llegeix_sessio: 'Llegint la sessió…', nova_sessio: 'Encarregant la feina…', envia_missatge: 'Enviant el missatge…', decideix_aprovacio: 'Decidint l’aprovació…' };
14
+ const EXAMPLES = ['Com va tot?', 'Quins agents tinc?', 'Què ha fet Claude Code?', 'Digues a Codex que passi els tests', 'Què espera el meu permís?'];
15
+ const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
16
+ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
17
+ const OLD = /ruta no existeix|Error 404/i; // un nio antic, sense trucades
18
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
19
+
20
+ // Captura del micròfon en un AudioWorklet: passa trossos de Float32 al fil principal.
21
+ const WORKLET = `class P extends AudioWorkletProcessor { process(i) { const c = i[0] && i[0][0]; if (c) this.port.postMessage(c.slice(0)); return true; } } registerProcessor('nio-mic', P);`;
22
+ const rateOf = (fmtName) => Number(String(fmtName || 'pcm_16000').split('_')[1]) || 16000;
23
+ function b64(bytes) { let s = ''; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }
24
+ function unb64(s) { const bin = atob(s), out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }
25
+
26
+ export function createCall(ctx) {
27
+ const { api, esc, toast, bot } = ctx;
28
+ let root = null, call = null;
29
+
30
+ // ---------------------------------------------------------------- pàgina d'abans de trucar
31
+ async function mount(el) {
32
+ root = el;
33
+ root.innerHTML = page(null);
34
+ if (!ctx.machine()) return;
35
+ let st = null;
36
+ if (!ctx.isDemo()) { try { st = await api('/api/call'); } catch (e) { st = { error: e.message }; } }
37
+ if (root === el) { root.innerHTML = page(st); }
38
+ }
39
+ function unmount() { root = null; }
40
+ function page(st) {
41
+ const m = ctx.machine(), demo = ctx.isDemo();
42
+ const head = `<div class="top"><div><h1>Trucada</h1><p>Parla amb en Nio com en una trucada: et diu quins agents tens, com van les sessions i què espera el teu permís. També li pots encarregar feina.</p></div></div>`;
43
+ if (!m) return head + ctx.noMachine('La trucada parla amb un ordinador. Connecta’n un per trucar.');
44
+ const ready = demo || st?.ready, loading = !demo && !st;
45
+ const why = st?.error ? `<p class="call-why err">${esc(OLD.test(st.error) ? 'Aquest ordinador encara no sap trucar: actualitza nio (cal nio-cat 0.9.9 o més nou).' : st.error)}</p>`
46
+ : !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs. Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs.</p>' : '';
47
+ return `${head}
48
+ <section class="call-hero">
49
+ <div class="call-face" aria-hidden="true"><span class="call-ring"></span><span class="call-ring r2"></span>${bot('idle', 'call-bot')}</div>
50
+ <h2>En Nio, a ${esc(m.name)}</h2>
51
+ <p class="muted">${demo ? 'A la demo, la trucada és simulada: veus com funciona sense micròfon ni clau.' : 'Veu en temps real amb ElevenLabs. Pots interrompre’l quan vulguis, com en una trucada.'}</p>
52
+ ${why}
53
+ <div class="call-cta">${ready || loading
54
+ ? `<button class="call-btn go" type="button" data-call="start" ${loading ? 'disabled' : ''}>${svg(I.call)}<span>${loading ? 'Comprovant…' : 'Truca'}</span></button>`
55
+ : '<button class="btn ink" type="button" data-call="voice">Afegeix la clau d’ElevenLabs</button>'}</div>
56
+ </section>
57
+ <section class="call-try"><h2 class="sec">Prova de dir-li</h2><div class="call-ex">${EXAMPLES.map((e) => `<span>«${esc(e)}»</span>`).join('')}</div>
58
+ <ul class="call-notes small muted"><li>Abans d’encarregar feina, enviar un missatge o aprovar res, en Nio t’ho repeteix i espera un sí.</li><li>Les feines que encarregues per veu s’obren amb «Demana permís».</li><li>La primera trucada crea l’agent «nio» al teu compte d’ElevenLabs. ElevenLabs cobra per minut de conversa.</li></ul></section>`;
59
+ }
60
+ document.addEventListener('click', (e) => {
61
+ const b = e.target.closest('[data-call]'); if (!b) return;
62
+ const a = b.dataset.call;
63
+ if (a === 'start') start();
64
+ else if (a === 'voice') ctx.voiceSheet();
65
+ });
66
+
67
+ // ---------------------------------------------------------------- pantalla de la trucada
68
+ // Com les lletres d'Apple Music: la conversa omple la pantalla, la frase d'ara és gran i s'il·lumina
69
+ // paraula per paraula al ritme de la veu d'en Nio; les anteriors s'apaguen i pugen.
70
+ function screen() {
71
+ const ov = document.createElement('div');
72
+ ov.className = 'call-ov'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Trucada amb en Nio');
73
+ ov.innerHTML = `
74
+ <div class="call-amb" aria-hidden="true"><i></i><i></i><i></i></div>
75
+ <header class="call-head">
76
+ <span class="call-me">${bot('work', 'call-bot')}<span><b>Nio</b><small id="cSt">Trucant…</small></span></span>
77
+ <span class="call-time" id="cTime"></span>
78
+ </header>
79
+ <div class="call-lyrics" id="cLyr" aria-live="polite"><div class="call-lines" id="cLines"></div></div>
80
+ <div class="call-bar">
81
+ <button class="call-ctl" type="button" data-c="mute" aria-pressed="false" aria-label="Silencia el micròfon">${svg(I.mic)}</button>
82
+ <button class="call-btn end" type="button" data-c="end" aria-label="Penja">${svg(I.hang)}</button>
83
+ </div>`;
84
+ document.body.append(ov);
85
+ if (!reduce) ov.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 280, easing: 'ease-out' });
86
+ ov.addEventListener('click', (e) => {
87
+ const c = e.target.closest('[data-c]')?.dataset.c;
88
+ if (c === 'end') end('Has penjat');
89
+ else if (c === 'mute') setMute(!call.muted);
90
+ else if (c === 'again') { cleanup(); call = null; ov.remove(); start(); }
91
+ else if (c === 'close') close();
92
+ });
93
+ ov.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (call?.live) end('Has penjat'); else close(); } });
94
+ // Si llisques per rellegir, el text no et salta a sota durant uns segons.
95
+ const lyr = ov.querySelector('#cLyr');
96
+ const hold = () => { if (call) call.userScroll = Date.now(); };
97
+ lyr.addEventListener('wheel', hold, { passive: true }); lyr.addEventListener('touchmove', hold, { passive: true });
98
+ ov.querySelector('[data-c="end"]').focus({ preventScroll: true });
99
+ return ov;
100
+ }
101
+ const $o = (s) => call?.ov.querySelector(s);
102
+ const now = () => (call?.audio ? call.audio.ctx.currentTime : performance.now() / 1000);
103
+ function setState(st, text) {
104
+ if (!call) return;
105
+ call.st = st;
106
+ call.ov.dataset.st = st;
107
+ $o('#cSt').textContent = text || { connecting: 'Trucant…', listening: call.muted ? 'Micròfon silenciat' : 'T’escolta', thinking: 'Pensant…', speaking: 'Parlant', ended: 'Trucada acabada' }[st] || '';
108
+ const b = $o('.call-bot'); if (b) b.dataset.mood = st === 'speaking' ? 'happy' : st === 'thinking' || st === 'connecting' ? 'work' : st === 'ended' ? 'sleep' : 'idle';
109
+ if (st === 'listening' && !call.muted) hearing(true); else if (st !== 'listening') hearing(false);
110
+ }
111
+ function setMute(on) {
112
+ call.muted = on;
113
+ const b = $o('[data-c="mute"]'); b.setAttribute('aria-pressed', on); b.setAttribute('aria-label', on ? 'Activa el micròfon' : 'Silencia el micròfon'); b.innerHTML = svg(on ? I.micOff : I.mic);
114
+ if (call.st === 'listening') setState('listening');
115
+ }
116
+
117
+ // ---- Les línies
118
+ function addLine(who, text = '', focus = true) {
119
+ const el = document.createElement('p'); el.className = `call-line ${who}`;
120
+ const line = { who, text: '', el, words: [], t0: 0, t1: 0, chars: [], spoken: -1 };
121
+ $o('#cLines').append(el); call.lines.push(line);
122
+ setText(line, text); if (focus) focusLine(line);
123
+ if (!reduce) el.animate([{ opacity: 0, transform: 'translateY(18px)' }, { opacity: 1, transform: 'none' }], { duration: 420, easing: 'cubic-bezier(.22,1,.36,1)' });
124
+ return line;
125
+ }
126
+ function setText(line, text) {
127
+ if (line.text === text) return;
128
+ line.text = text;
129
+ if (line.who !== 'nio') { line.el.textContent = text; return; }
130
+ const parts = text.split(/(\s+)/);
131
+ line.el.innerHTML = parts.map((p) => (/^\s+$/.test(p) || !p ? p : `<span>${esc(p)}</span>`)).join('');
132
+ line.words = [...line.el.children]; line.spoken = -1;
133
+ // Posició (en caràcters) del final de cada paraula, per il·luminar-les al ritme de la veu.
134
+ let n = 0; line.ends = parts.map((p) => (n += p.length)).filter((_, i) => parts[i] && !/^\s+$/.test(parts[i]));
135
+ paint(line);
136
+ }
137
+ function focusLine(line) {
138
+ for (const l of call.lines) l.el.classList.toggle('now', l === line);
139
+ call.focus = line; scrollTo(line);
140
+ }
141
+ function scrollTo(line) {
142
+ const lyr = $o('#cLyr'); if (!lyr || Date.now() - (call.userScroll || 0) < 4000) return;
143
+ lyr.scrollTo({ top: Math.max(0, line.el.offsetTop - lyr.clientHeight * 0.3), behavior: reduce ? 'auto' : 'smooth' });
144
+ }
145
+ // Fins a quin caràcter ha parlat en Nio: amb l'alineació d'ElevenLabs si n'hi ha; si no, pel temps d'àudio.
146
+ function spokenChars(line, t) {
147
+ if (line.chars.length) {
148
+ let k = 0; while (k < line.chars.length && line.chars[k] <= t) k++;
149
+ return line.text.length * (k / line.chars.length);
150
+ }
151
+ if (!line.t1 || line.t1 <= line.t0) return line.done ? line.text.length : 0;
152
+ return line.text.length * Math.min(1, Math.max(0, (t - line.t0) / (line.t1 - line.t0)));
153
+ }
154
+ function paint(line) {
155
+ if (line.who !== 'nio' || !line.words.length) return;
156
+ const c = line.done && now() >= line.t1 ? Infinity : spokenChars(line, now());
157
+ let k = 0; while (k < line.ends.length && line.ends[k] - 1 <= c) k++;
158
+ if (k === line.spoken) return;
159
+ line.spoken = k;
160
+ line.words.forEach((w, i) => w.classList.toggle('on', i < k));
161
+ }
162
+ // La línia d'en Nio d'aquest torn (se'n comença una si l'anterior ja s'ha tancat).
163
+ function nioLine() {
164
+ const l = call.cur;
165
+ // Una resposta nova després que l'anterior ja hagi sonat del tot: línia nova.
166
+ if (l && !l.closed && !(l.done && l.t1 && now() > l.t1 + 0.25)) return l;
167
+ call.cur = addLine('nio'); return call.cur;
168
+ }
169
+ function closeTurn() { if (call.cur) call.cur.closed = true; }
170
+ // Mentre parles, una línia teva amb tres punts que reaccionen a la veu; s'omple amb la transcripció.
171
+ function hearing(on) {
172
+ if (!call || call.ended) return;
173
+ if (on && !call.ear) { call.ear = addLine('tu ear', '', false); call.ear.el.innerHTML = '<i></i><i></i><i></i>'; call.ear.el.hidden = true; }
174
+ if (!on && call.ear && !call.ear.text) { call.ear.el.remove(); call.lines.splice(call.lines.indexOf(call.ear), 1); call.ear = null; }
175
+ }
176
+ // Porta una línia al final de la conversa (l'«orella» es crea abans de saber si parlaràs).
177
+ function toEnd(l) { $o('#cLines').append(l.el); call.lines.splice(call.lines.indexOf(l), 1); call.lines.push(l); }
178
+ function userSaid(text, final) {
179
+ closeTurn();
180
+ const l = call.ear || addLine('tu');
181
+ if (call.ear) toEnd(l);
182
+ l.el.hidden = false; l.el.classList.remove('ear'); l.who = 'tu';
183
+ setText(l, text); focusLine(l);
184
+ call.ear = final ? null : l;
185
+ }
186
+ function info(label) {
187
+ if (call.tool) { call.tool.el.remove(); call.lines.splice(call.lines.indexOf(call.tool), 1); call.tool = null; }
188
+ if (label) { call.tool = addLine('info'); setText(call.tool, label); }
189
+ }
190
+
191
+ function tick() {
192
+ if (!call?.live) return;
193
+ $o('#cTime').textContent = fmt((Date.now() - call.t0) / 1000);
194
+ const t = now();
195
+ let lvl = 0;
196
+ const speaking = call.audio ? t < call.audio.until - 0.02 : call.st === 'speaking';
197
+ if (call.audio?.inAn) {
198
+ const an = speaking ? call.audio.outAn : call.audio.inAn, buf = call.audio.buf;
199
+ an.getFloatTimeDomainData(buf); let sum = 0; for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
200
+ lvl = Math.min(1, Math.sqrt(sum / buf.length) * (speaking ? 4 : 6));
201
+ if (call.muted && !speaking) lvl = 0;
202
+ } else if (call.demo) lvl = speaking ? 0.3 + Math.random() * 0.4 : call.st === 'listening' ? Math.random() * 0.12 : 0;
203
+ call.lvl = call.lvl * 0.75 + lvl * 0.25;
204
+ call.ov.style.setProperty('--lvl', call.lvl.toFixed(3));
205
+ // Els tres punts només surten quan de debò parles.
206
+ if (call.ear && !call.ear.text) { const show = !speaking && call.lvl > 0.12; if (show && call.ear.el.hidden) { toEnd(call.ear); call.ear.el.hidden = false; focusLine(call.ear); } }
207
+ for (const l of call.lines) if (l.who === 'nio' && l.spoken < l.words.length) paint(l);
208
+ if (call.audio && call.st !== 'connecting') {
209
+ if (speaking && call.st !== 'speaking') setState('speaking');
210
+ else if (!speaking && call.st === 'speaking') { setState('listening'); if (call.hangAfter) end('En Nio ha penjat'); }
211
+ }
212
+ call.raf = requestAnimationFrame(tick);
213
+ }
214
+
215
+ // ---------------------------------------------------------------- començar i acabar
216
+ const fresh = (extra) => ({ lines: [], muted: false, live: false, t0: 0, lvl: 0, st: 'connecting', seen: new Map(), approvals: new Set(), ...extra });
217
+ async function start() {
218
+ if (call) return;
219
+ if (ctx.isDemo()) return demoCall();
220
+ if (!navigator.mediaDevices?.getUserMedia || !window.WebSocket || !window.AudioContext) return toast('Aquest navegador no pot fer trucades. Prova Chrome o Safari recents.', 4500);
221
+ call = fresh();
222
+ call.ov = screen(); setState('connecting');
223
+ // L'àudio s'ha d'engegar dins del toc (iPhone): el context i el micròfon, abans de res més.
224
+ try {
225
+ const actx = new AudioContext({ latencyHint: 'interactive' });
226
+ await actx.resume();
227
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 } });
228
+ call.audio = { ctx: actx, stream, until: 0, srcs: new Set() };
229
+ } catch (e) {
230
+ return fail(/denied|NotAllowed|permission/i.test(`${e.name} ${e.message}`) ? 'No tinc permís per al micròfon. Activa’l als ajustos del navegador i torna-ho a provar.' : `No he pogut obrir el micròfon: ${e.message}`);
231
+ }
232
+ let s;
233
+ try { s = await api('/api/call/start', { method: 'POST', body: {} }); }
234
+ catch (e) { return fail(OLD.test(e.message) ? 'Aquest ordinador encara no sap trucar: actualitza nio (cal nio-cat 0.9.9 o més nou).' : e.message); }
235
+ if (!call) return;
236
+ for (const x of ctx.sessions()) call.seen.set(x.id, x.state);
237
+ call.approvals = new Set(ctx.approvals());
238
+ connect(s);
239
+ }
240
+ function connect(s) {
241
+ const ws = new WebSocket(s.signedUrl); call.ws = ws;
242
+ ws.onopen = () => ws.send(JSON.stringify(s.init));
243
+ ws.onmessage = (m) => { let d; try { d = JSON.parse(m.data); } catch { return; } onMsg(d); };
244
+ ws.onerror = () => {};
245
+ ws.onclose = (e) => {
246
+ if (!call || call.ws !== ws) return;
247
+ if (call.live) end(e.code === 1000 ? 'Trucada acabada' : `S’ha tallat${e.reason ? `: ${e.reason}` : ''}`);
248
+ else fail(e.reason ? `ElevenLabs: ${e.reason}` : 'No he pogut connectar amb ElevenLabs.');
249
+ };
250
+ }
251
+ async function onMsg(d) {
252
+ if (!call) return;
253
+ const send = (o) => { if (call.ws?.readyState === 1) call.ws.send(JSON.stringify(o)); };
254
+ switch (d.type) {
255
+ case 'conversation_initiation_metadata': {
256
+ const md = d.conversation_initiation_metadata_event || {};
257
+ call.inRate = rateOf(md.user_input_audio_format); call.outRate = rateOf(md.agent_output_audio_format);
258
+ await startMic();
259
+ call.live = true; call.t0 = Date.now();
260
+ try { call.lock = await navigator.wakeLock?.request('screen'); } catch { /* sense bloqueig */ }
261
+ navigator.vibrate?.(20);
262
+ setState('listening'); tick();
263
+ break;
264
+ }
265
+ case 'audio': play(d.audio_event); break;
266
+ case 'agent_response': { if (call.cur?.done && call.cur.text) closeTurn(); const l = nioLine(); setText(l, d.agent_response_event?.agent_response || ''); l.done = true; info(''); break; }
267
+ case 'agent_response_correction': { const l = call.lines.filter((x) => x.who === 'nio').at(-1); if (l) { setText(l, d.agent_response_correction_event?.corrected_agent_response || l.text); l.done = true; } break; }
268
+ case 'tentative_user_transcript': { const t = d.tentative_user_transcription_event?.user_transcript; if (t?.trim()) userSaid(t, false); break; }
269
+ case 'user_transcript': { const t = d.user_transcription_event?.user_transcript; if (t?.trim()) { userSaid(t, true); if (call.st !== 'speaking') setState('thinking'); } break; }
270
+ case 'interruption': {
271
+ // Tallat a mitja frase: es queda el que s'ha arribat a dir.
272
+ const l = call.cur;
273
+ if (l && !l.closed && l.words.length) { const k = Math.max(1, l.spoken); setText(l, `${l.words.slice(0, k).map((w) => w.textContent).join(' ')}…`); l.done = true; l.t1 = 0; }
274
+ closeTurn(); stopPlayback(); setState('listening');
275
+ break;
276
+ }
277
+ case 'ping': send({ type: 'pong', event_id: d.ping_event?.event_id }); break;
278
+ case 'client_tool_call': {
279
+ const c = d.client_tool_call || {};
280
+ if (c.tool_name === 'penja') { call.hangAfter = true; if (call.st !== 'speaking') setTimeout(() => call?.hangAfter && end('En Nio ha penjat'), 1200); if (c.expects_response) send({ type: 'client_tool_result', tool_call_id: c.tool_call_id, result: 'D’acord', is_error: false }); break; }
281
+ closeTurn(); info(TOOL_LABEL[c.tool_name] || 'Treballant…');
282
+ try {
283
+ const r = await api('/api/call/tool', { method: 'POST', body: { name: c.tool_name, parameters: c.parameters || {} } });
284
+ send({ type: 'client_tool_result', tool_call_id: c.tool_call_id, result: String(r.result ?? ''), is_error: false });
285
+ } catch (e) { send({ type: 'client_tool_result', tool_call_id: c.tool_call_id, result: e.message, is_error: true }); }
286
+ break;
287
+ }
288
+ case 'client_error': case 'error': toast(d.error_event?.message || 'Error de la trucada', 4500); break;
289
+ }
290
+ }
291
+ async function startMic() {
292
+ const A = call.audio, actx = A.ctx;
293
+ const src = actx.createMediaStreamSource(A.stream);
294
+ A.inAn = actx.createAnalyser(); A.inAn.fftSize = 512; A.buf = new Float32Array(512);
295
+ A.out = actx.createGain(); A.outAn = actx.createAnalyser(); A.outAn.fftSize = 512;
296
+ A.out.connect(A.outAn); A.outAn.connect(actx.destination);
297
+ src.connect(A.inAn);
298
+ // Es remostreja a la freqüència que vol ElevenLabs (normalment 16 kHz) i s'envia cada ~100 ms.
299
+ const ratio = actx.sampleRate / call.inRate, chunk = Math.round(call.inRate / 10);
300
+ let pend = new Float32Array(0), pos = 0;
301
+ const onFrame = (f) => {
302
+ if (!call?.ws || call.ws.readyState !== 1) return;
303
+ const merged = new Float32Array(pend.length + f.length); merged.set(pend); merged.set(f, pend.length); pend = merged;
304
+ const n = Math.floor((pend.length - pos) / ratio); if (n <= 0) return;
305
+ const out = new Int16Array(n);
306
+ for (let i = 0; i < n; i++) { const v = call.muted ? 0 : pend[Math.floor(pos + i * ratio)]; out[i] = Math.max(-1, Math.min(1, v)) * 0x7fff; }
307
+ pos += n * ratio; const used = Math.floor(pos); pend = pend.slice(used); pos -= used;
308
+ A.q = A.q ? concat16(A.q, out) : out;
309
+ if (A.q.length >= chunk) { call.ws.send(JSON.stringify({ user_audio_chunk: b64(new Uint8Array(A.q.buffer)) })); A.q = null; }
310
+ };
311
+ const mute = actx.createGain(); mute.gain.value = 0; mute.connect(actx.destination);
312
+ if (actx.audioWorklet) {
313
+ const url = URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' }));
314
+ await actx.audioWorklet.addModule(url); URL.revokeObjectURL(url);
315
+ const node = new AudioWorkletNode(actx, 'nio-mic'); node.port.onmessage = (e) => onFrame(e.data);
316
+ src.connect(node); node.connect(mute); A.node = node;
317
+ } else {
318
+ const node = actx.createScriptProcessor(2048, 1, 1); node.onaudioprocess = (e) => onFrame(e.inputBuffer.getChannelData(0).slice(0));
319
+ src.connect(node); node.connect(mute); A.node = node;
320
+ }
321
+ }
322
+ function concat16(a, b) { const o = new Int16Array(a.length + b.length); o.set(a); o.set(b, a.length); return o; }
323
+ function play(ev) {
324
+ const data = ev?.audio_base_64;
325
+ if (!data || !call?.audio?.out) return;
326
+ const A = call.audio, bytes = unb64(data), pcm = new Int16Array(bytes.buffer, 0, bytes.length >> 1);
327
+ if (!pcm.length) return;
328
+ const buf = A.ctx.createBuffer(1, pcm.length, call.outRate), ch = buf.getChannelData(0);
329
+ for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 0x8000;
330
+ const src = A.ctx.createBufferSource(); src.buffer = buf; src.connect(A.out);
331
+ const at = Math.max(A.ctx.currentTime + 0.03, A.until);
332
+ src.start(at); A.until = at + buf.duration;
333
+ A.srcs.add(src); src.onended = () => A.srcs.delete(src);
334
+ // La línia d'aquest torn sap quan sona cada caràcter (alineació) o, si no, quant dura l'àudio.
335
+ info('');
336
+ const l = nioLine();
337
+ if (!l.t0) l.t0 = at;
338
+ l.t1 = A.until;
339
+ const al = ev.alignment;
340
+ if (al?.chars?.length && al.char_start_times_ms?.length) {
341
+ for (let i = 0; i < al.chars.length; i++) l.chars.push(at + al.char_start_times_ms[i] / 1000);
342
+ if (!l.done) setText(l, `${l.text}${al.chars.join('')}`);
343
+ }
344
+ if (call.focus !== l) focusLine(l);
345
+ }
346
+ function stopPlayback() { const A = call?.audio; if (!A) return; for (const s of A.srcs) { try { s.stop(); } catch { /* ja aturat */ } } A.srcs.clear(); A.until = 0; }
347
+
348
+ // Avisos en directe: si una sessió acaba o un agent demana permís durant la trucada, en Nio ho sap.
349
+ function onEvent(msg) {
350
+ if (!call?.live || !call.ws || call.ws.readyState !== 1) return;
351
+ let text = '';
352
+ if (msg.type === 'session') {
353
+ const s = msg.session, was = call.seen.get(s.id); call.seen.set(s.id, s.state);
354
+ const name = `${ctx.agentName(s.agent)} a ${String(s.cwd).split('/').pop()}`;
355
+ if (was === 'running' && s.state === 'idle') text = `Avís: ${name} ha acabat «${s.title || 'la sessió'}». Últim missatge: ${String(s.last || '').slice(0, 200)}`;
356
+ else if (was !== 'error' && s.state === 'error') text = `Avís: ${name} ha fallat: ${String(s.error || '').slice(0, 160)}`;
357
+ } else if (msg.type === 'approval' && msg.approval.state === 'pending' && !call.approvals.has(msg.approval.id)) {
358
+ call.approvals.add(msg.approval.id);
359
+ text = `Avís: ${ctx.agentName(msg.approval.agent)} demana permís per ${msg.approval.tool}: ${String(msg.approval.title || '').slice(0, 80)}. Ho pots aprovar o rebutjar amb l’eina si la persona vol.`;
360
+ }
361
+ if (text) call.ws.send(JSON.stringify({ type: 'contextual_update', text }));
362
+ }
363
+
364
+ function fail(msg) {
365
+ if (!call) return;
366
+ cleanup();
367
+ setState('ended', 'No s’ha pogut trucar');
368
+ const l = addLine('err'); setText(l, msg);
369
+ finishBar();
370
+ }
371
+ function end(why) {
372
+ if (!call || call.ended) return;
373
+ const secs = call.live ? (Date.now() - call.t0) / 1000 : 0;
374
+ cleanup();
375
+ navigator.vibrate?.([12, 60, 12]);
376
+ setState('ended', why || 'Trucada acabada');
377
+ $o('#cTime').textContent = secs ? fmt(secs) : '';
378
+ call.ov.style.setProperty('--lvl', '0');
379
+ // Tota la conversa queda per rellegir, ja il·luminada.
380
+ for (const l of call.lines) { l.done = true; l.t1 = 0; l.chars = []; paint(l); l.el.classList.remove('now'); }
381
+ call.ov.classList.add('over');
382
+ finishBar();
383
+ }
384
+ function finishBar() {
385
+ info('');
386
+ $o('.call-bar').innerHTML = `<button class="call-ctl" type="button" data-c="close" aria-label="Tanca">${svg(I.close)}</button><button class="call-btn go" type="button" data-c="again">${svg(I.call)}<span>Torna a trucar</span></button>`;
387
+ $o('[data-c="again"]').focus({ preventScroll: true });
388
+ }
389
+ function cleanup() {
390
+ if (!call) return;
391
+ call.ended = true; call.live = false; cancelAnimationFrame(call.raf); clearTimeout(call.demoT);
392
+ if (call.ear && !call.ear.text) { call.ear.el.remove(); call.lines.splice(call.lines.indexOf(call.ear), 1); call.ear = null; }
393
+ const ws = call.ws; call.ws = null; try { ws?.close(1000); } catch { /* ja tancat */ }
394
+ stopPlayback();
395
+ const A = call.audio; if (A) { A.stream?.getTracks().forEach((t) => t.stop()); try { A.node?.disconnect(); } catch { /* res */ } A.ctx.close().catch(() => {}); call.audio = null; }
396
+ try { call.lock?.release(); } catch { /* res */ }
397
+ }
398
+ function close() {
399
+ if (!call) return;
400
+ const ov = call.ov; cleanup(); call = null;
401
+ if (reduce) ov.remove(); else ov.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, easing: 'ease-in' }).onfinish = () => ov.remove();
402
+ }
403
+
404
+ // ---------------------------------------------------------------- demo: una trucada de mostra, sense àudio
405
+ function demoCall() {
406
+ call = fresh({ live: true, t0: Date.now(), demo: true });
407
+ call.ov = screen(); setState('connecting'); tick();
408
+ const agents = [...new Set(ctx.sessions().map((s) => ctx.agentName(s.agent)))];
409
+ const list = agents.length > 1 ? `${agents.slice(0, -1).join(', ')} i ${agents.at(-1)}` : agents[0] || 'cap agent';
410
+ // En Nio «parla» una frase: les paraules s'il·luminen durant el temps que trigaria a dir-la.
411
+ const speak = (text) => { info(''); closeTurn(); const l = nioLine(); setText(l, text); l.t0 = now(); l.t1 = l.t0 + text.length / 15; l.done = true; setState('speaking'); return (l.t1 - l.t0) * 1000; };
412
+ const hear = (text) => { setState('listening'); userSaid(text, true); };
413
+ const script = [
414
+ [1100, () => speak(`Hola! Sóc en Nio, et parlo des de l’ordinador ${ctx.machine().name}. Tens ${list}. Hi ha una acció esperant el teu permís. Què vols fer?`)],
415
+ [null, () => setState('listening')],
416
+ [1500, () => hear('Què vol fer Codex?')],
417
+ [500, () => { setState('thinking'); info(TOOL_LABEL.estat); }],
418
+ [1300, () => speak('Codex vol executar la migració de la base de dades, a la carpeta api. L’aprovo?')],
419
+ [null, () => setState('listening')],
420
+ [1400, () => hear('Sí, aprova-la.')],
421
+ [500, () => { setState('thinking'); info(TOOL_LABEL.decideix_aprovacio); }],
422
+ [1100, () => speak('Fet, aprovada. Codex ja hi torna a treballar. Alguna cosa més?')],
423
+ [null, () => setState('listening', 'T’escolta · és una demo')],
424
+ ];
425
+ let i = 0, wait = 0;
426
+ const next = () => {
427
+ if (!call?.demo || call.ended || i >= script.length) return;
428
+ const [w, f] = script[i++];
429
+ call.demoT = setTimeout(() => { if (!call || call.ended) return; const r = f(); wait = typeof r === 'number' ? r + 300 : 0; next(); }, w ?? wait);
430
+ };
431
+ next();
432
+ }
433
+
434
+ return { mount, unmount, onEvent, get active() { return !!call; } };
435
+ }
package/web/app/demo.js CHANGED
@@ -314,6 +314,7 @@
314
314
  if (method === 'GET') {
315
315
  if (p === '/api/info') return { name: 'nio', version: 'demo', host: 'Ordinador de demo', platform: 'darwin', user: 'tu', publicUrl: null, agents: AGENTS, roots: [ROOT], vapidKey: null, id: 'demo', device: { id: 'demo', name: 'Aquest navegador', primary: true }, demo: true, control: caps };
316
316
  if (p === '/api/usage') return DEMO_USAGE;
317
+ if (p === '/api/claude-sessions') return { sessions: [{ sessionId: 'demo-terminal', pid: 1, cwd: `${ROOT}/web-botiga`, kind: 'interactive', status: 'busy', name: '', title: 'Arregla el pagament amb targeta', startedAt: Date.now() - 40 * 60000, updated: Date.now() - 2 * 60000, ask: 'Els pagaments amb targeta fallen quan el client canvia de moneda. Mira-ho i arregla-ho.', last: '', terminal: { app: 'iTerm', tmux: false, screen: false, ssh: false }, transcript: true }] };
317
318
  if (p === '/api/notes') return { notes: NOTES };
318
319
  if (p === '/api/notes/engines') return { engines: [{ id: 'ollama:qwen3:4b', label: 'qwen3:4b · 4.0B', local: true, group: 'Local (Ollama)', params: 4 }] };
319
320
  if (p === '/api/sessions') return list();
@@ -36,6 +36,7 @@
36
36
  <link rel="stylesheet" href="/board.css">
37
37
  <link rel="stylesheet" href="/app/style.css">
38
38
  <link rel="stylesheet" href="/app/notes.css">
39
+ <link rel="stylesheet" href="/app/call.css">
39
40
  </head>
40
41
  <body>
41
42
  <header class="mbar">