@ing.jorgeu/wargaming-overlay 2.5.0 → 3.0.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/PUBLISHING.md +1 -1
- package/README.md +156 -11
- package/THIRD_PARTY_ASSETS.md +8 -0
- package/battle_reader.py +4 -1
- package/cli.js +24 -7
- package/game-store.js +2 -1
- package/package.json +16 -6
- package/public/army-list.css +18 -0
- package/public/army-list.html +1 -0
- package/public/army-list.js +59 -0
- package/public/control.html +2 -2
- package/public/control.js +2 -1
- package/public/detachments.css +2 -2
- package/public/roster-view.js +8 -0
- package/public/rosters-control.js +77 -0
- package/public/style.css +6 -0
- package/rosters.js +39 -0
- package/scripts/setup-kokoro.js +2 -0
- package/scripts/tts-demo.js +0 -1
- package/server.js +32 -4
- package/tts/kokoro-assets.js +27 -0
- package/tts/kokoro-engine.js +31 -0
- package/tts/kokoro-worker.js +13 -0
- package/tts/licenses/espeak-ng-COPYING +674 -0
- package/tts/playback.js +28 -0
- package/tts/speech.js +55 -3
- package/tts/voice-command.js +14 -0
- package/tts/wav.js +6 -0
- package/tts/windows-playback.js +14 -0
- package/youtube/index.js +5 -2
- package/youtube/live-chat.js +5 -3
- package/youtube/poll-chat.js +15 -0
- package/youtube/service.js +18 -9
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
(()=>{
|
|
2
|
+
const $=s=>document.querySelector(s),root=$('#roster-editors'),feedback=$('#roster-feedback');
|
|
3
|
+
const drafts=new Map();let snapshot,dragged=null;
|
|
4
|
+
const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!=null)n.textContent=text;if(cls)n.className=cls;return n;};
|
|
5
|
+
function tab(which){for(const name of ['match','rosters']){const selected=name===which;$('#'+name+'-panel').hidden=!selected;$('#tab-'+name).setAttribute('aria-pressed',String(selected));$('#tab-'+name).classList.toggle('secondary-button',!selected);}if(which==='rosters')load();}
|
|
6
|
+
$('#tab-match').onclick=()=>tab('match');$('#tab-rosters').onclick=()=>tab('rosters');
|
|
7
|
+
function dirty(){return [...drafts.values()].some(d=>d.dirty);}
|
|
8
|
+
async function load(){
|
|
9
|
+
if([...drafts.values()].some(d=>d.saving))return;
|
|
10
|
+
try{const r=await fetch('/rosters');if(!r.ok)throw Error('No se pudieron cargar las listas.');snapshot=await r.json();
|
|
11
|
+
for(const p of Object.values(snapshot.players)){if(!drafts.has(p.rosterId)||!drafts.get(p.rosterId).dirty)drafts.set(p.rosterId,{...structuredClone(snapshot.rosters[p.rosterId]||{revision:0,units:[]}),dirty:false});}
|
|
12
|
+
render();
|
|
13
|
+
}catch(e){feedback.textContent=e.message;}
|
|
14
|
+
}
|
|
15
|
+
function button(text,fn,secondary=true){const b=el('button',text,secondary?'secondary-button':'');b.type='button';b.onclick=fn;return b;}
|
|
16
|
+
function field(label,value,max,fn,multiline=false){const l=el('label',label),i=el(multiline?'textarea':'input');i.value=value;i.maxLength=max;if(multiline)i.rows=2;i.oninput=()=>fn(i.value);l.append(i);return l;}
|
|
17
|
+
function render(){
|
|
18
|
+
root.replaceChildren();if(!snapshot)return;
|
|
19
|
+
for(const [side,p] of Object.entries(snapshot.players)){
|
|
20
|
+
const d=drafts.get(p.rosterId),section=el('article',null,'roster-editor');section.dataset.roster=p.rosterId;
|
|
21
|
+
section.append(el('h3',p.name||'Jugador'),el('p',[p.faction,...(p.detachments||[])].filter(Boolean).join(' · '),'roster-meta'));
|
|
22
|
+
const color=side==='left'?'blue':'red',url=location.origin+'/army-list.html?side='+color;
|
|
23
|
+
const link=el('a','Abrir lista para OBS ↗');link.href=url;link.target='_blank';link.rel='noopener';section.append(link,el('code',url,'roster-url'),el('p','Fuente Navegador: 1920 × 1080. Toda la lista se muestra en una sola pantalla.'));
|
|
24
|
+
section.append(button('Copiar URL '+(color==='blue'?'azul':'roja'),async()=>{try{await navigator.clipboard.writeText(url);feedback.textContent='URL copiada al portapapeles.';}catch{feedback.textContent='No se pudo copiar. Selecciona y copia la URL que aparece arriba.';}}));
|
|
25
|
+
const toolbar=el('div',null,'roster-toolbar');
|
|
26
|
+
const change=()=>{d.dirty=true;d.version=(d.version||0)+1;feedback.textContent='Cambios pendientes…';schedule(p.rosterId);};
|
|
27
|
+
section.append(field('Nombre de la lista',d.name||'',80,v=>{d.name=v;change();}));
|
|
28
|
+
section.append(field('URL de imagen de fondo (opcional)',d.backgroundUrl||'',2048,v=>{d.backgroundUrl=v;change();}));
|
|
29
|
+
toolbar.append(button('Añadir unidad',()=>{if(d.units.length>=80){feedback.textContent='Máximo 80 unidades.';return;}d.units.push({id:crypto.randomUUID(),name:'',enhancement:'',characters:[],notes:'',deepStrike:false,destroyed:false,battleshocked:false});change();render();sectionFor(p.rosterId)?.querySelector('.unit-editor:last-child input')?.focus();}));
|
|
30
|
+
const upload=el('input');upload.type='file';upload.accept='.json,application/json';upload.hidden=true;
|
|
31
|
+
upload.onchange=async()=>{try{const file=upload.files[0];if(!file)return;if(file.size>200000)throw Error('El archivo es demasiado grande.');const json=JSON.parse(await file.text());const units=Array.isArray(json)?json:json.units;if(!Array.isArray(units)||units.length>80)throw Error('El archivo debe contener una lista de hasta 80 unidades.');
|
|
32
|
+
const clean=units.map(u=>{if(typeof u?.name!=='string'||!u.name.trim()||u.name.length>100||!Array.isArray(u.characters??[])||(u.characters??[]).some(c=>typeof c!=='string'||!c.trim()||c.length>80)||(u.characters??[]).length>8||typeof(u.enhancement??'')!=='string'||(u.enhancement??'').length>100||typeof(u.notes??'')!=='string'||(u.notes??'').length>160)throw Error('Una unidad tiene campos inválidos. Consulta el formato del README.');return {id:crypto.randomUUID(),name:u.name,enhancement:u.enhancement||'',characters:u.characters||[],notes:u.notes||'',deepStrike:u.deepStrike===true,destroyed:u.destroyed===true,battleshocked:u.battleshocked===true};});
|
|
33
|
+
if(d.units.length&&!confirm('¿Reemplazar esta lista con el archivo?'))return;
|
|
34
|
+
d.units=clean;d.name=typeof json.name==='string'?json.name:d.name||'';d.backgroundUrl=typeof json.backgroundUrl==='string'?json.backgroundUrl:d.backgroundUrl||'';change();render();
|
|
35
|
+
}catch(e){feedback.textContent=e.message;}finally{upload.value='';}};
|
|
36
|
+
toolbar.append(button('Importar JSON',()=>upload.click()),upload,button('Exportar JSON',()=>{const a=el('a');a.href=URL.createObjectURL(new Blob([JSON.stringify({name:d.name||'',backgroundUrl:d.backgroundUrl||'',units:d.units},null,2)],{type:'application/json'}));a.download='lista-ejercito.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);}));
|
|
37
|
+
section.append(toolbar);
|
|
38
|
+
const list=el('ol',null,'unit-editors');
|
|
39
|
+
d.units.forEach((u,index)=>{
|
|
40
|
+
const row=el('li',null,'unit-editor');const tools=el('div',null,'unit-tools');const handle=button('⠿',()=>{});handle.className='drag-handle';handle.draggable=true;handle.setAttribute('aria-label','Arrastrar '+(u.name||'unidad'));handle.title='Arrastra para ordenar';
|
|
41
|
+
handle.draggable=false;
|
|
42
|
+
handle.onpointerdown=e=>{if(e.button!==0)return;e.preventDefault();handle.setPointerCapture(e.pointerId);dragged={roster:p.rosterId,id:u.id,startY:e.clientY,target:null};row.classList.add('dragging');};
|
|
43
|
+
handle.onpointermove=e=>{if(!dragged||dragged.id!==u.id)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest('.unit-editor');root.querySelectorAll('.drag-over').forEach(n=>n.classList.remove('drag-over'));if(target&&list.contains(target)){dragged.target=target;dragged.after=e.clientY>target.getBoundingClientRect().top+target.getBoundingClientRect().height/2;target.classList.add('drag-over');}if(e.clientY<70)window.scrollBy(0,-25);else if(e.clientY>innerHeight-70)window.scrollBy(0,25);};
|
|
44
|
+
const finishDrag=e=>{if(!dragged||dragged.id!==u.id)return;const drag=dragged;dragged=null;root.querySelectorAll('.drag-over,.dragging').forEach(n=>n.classList.remove('drag-over','dragging'));if(e.type==='pointercancel'||!drag.target||Math.abs(e.clientY-drag.startY)<4)return;const from=d.units.findIndex(x=>x.id===u.id);let to=[...list.children].indexOf(drag.target)+(drag.after?1:0);const [moved]=d.units.splice(from,1);if(from<to)to--;d.units.splice(to,0,moved);change();render();};
|
|
45
|
+
handle.onpointerup=finishDrag;handle.onpointercancel=finishDrag;
|
|
46
|
+
tools.append(handle);const heading=el('strong',`${index+1}. ${u.name||'Nueva unidad'}`);tools.append(heading);
|
|
47
|
+
for(const [delta,label] of [[-1,'Subir ↑'],[1,'Bajar ↓']]){const b=button(label,()=>{const j=index+delta;[d.units[index],d.units[j]]=[d.units[j],d.units[index]];change();render();});b.disabled=index+delta<0||index+delta>=d.units.length;tools.append(b);}
|
|
48
|
+
tools.append(button('Eliminar',()=>{if(u.name&&!confirm(`¿Eliminar ${u.name}?`))return;d.units.splice(index,1);change();render();}));row.append(tools);
|
|
49
|
+
const details=el('details',null,'unit-fields');details.open=!u.name;details.append(el('summary','Editar nombre, mejora, personajes y notas'));
|
|
50
|
+
details.append(field('Nombre de la unidad',u.name,100,v=>{u.name=v;heading.textContent=`${index+1}. ${v||'Nueva unidad'}`;change();}),field('Mejora (opcional)',u.enhancement,100,v=>{u.enhancement=v;change();}),field('Personajes asignados · uno por línea (máximo 8)',u.characters.join('\n'),647,v=>{u.characters=v.split('\n').filter(s=>s.trim());change();},true),field('Notas (opcional)',u.notes,160,v=>{u.notes=v;change();},true));
|
|
51
|
+
row.append(details);
|
|
52
|
+
const flags=el('div',null,'unit-flags');for(const [key,label] of [['deepStrike','En reservas'],['battleshocked','Battleshocked'],['destroyed','Unidad destruida']]){const l=el('label'),box=el('input');box.type='checkbox';box.checked=u[key];box.onchange=()=>{u[key]=box.checked;change();};l.append(box,document.createTextNode(label));flags.append(l);}row.append(flags);list.append(row);
|
|
53
|
+
});
|
|
54
|
+
if(!d.units.length)section.append(el('p','Todavía no hay unidades. Añade la primera o importa una lista.','roster-empty'));
|
|
55
|
+
section.append(list);root.append(section);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const sectionFor=id=>[...root.children].find(n=>n.dataset.roster===id);
|
|
59
|
+
function schedule(id,delay=600){const d=drafts.get(id);clearTimeout(d.timer);d.timer=setTimeout(()=>save(id),delay);}
|
|
60
|
+
async function save(id){
|
|
61
|
+
const d=drafts.get(id);if(!d||!d.dirty||d.saving)return;
|
|
62
|
+
if(d.units.some(u=>!u.name.trim())){feedback.textContent='Escribe el nombre de cada unidad para guardar automáticamente.';return;}
|
|
63
|
+
const version=d.version;d.saving=true;feedback.textContent='Guardando lista…';let retry=false;
|
|
64
|
+
try{
|
|
65
|
+
const r=await fetch('/rosters/'+encodeURIComponent(id),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({revision:d.revision,name:d.name||'',units:d.units,backgroundUrl:d.backgroundUrl||''})});
|
|
66
|
+
const data=await r.json();if(!r.ok){retry=r.status>=500;throw Error(data.error||'No se pudo guardar.');}
|
|
67
|
+
d.revision=data.revision;
|
|
68
|
+
if(typeof data.backgroundUrl!=='string'||typeof data.name!=='string'||data.units.some(u=>typeof u.battleshocked!=='boolean')){retry=true;throw Error('Reinicia npm start para actualizar el servicio. Conservamos los cambios y reintentaremos automáticamente.');}
|
|
69
|
+
if(d.version===version)d.dirty=false;
|
|
70
|
+
feedback.textContent=dirty()?'Cambios pendientes…':'Listas guardadas. OBS actualizado.';
|
|
71
|
+
}catch(e){if(e instanceof TypeError)retry=true;feedback.textContent=e.message+(retry?' Reintentando en unos segundos.':' Los cambios siguen en el editor.');}
|
|
72
|
+
finally{d.saving=false;if(retry)schedule(id,5000);else if(d.dirty&&d.version!==version)schedule(id);}
|
|
73
|
+
}
|
|
74
|
+
window.addEventListener('online',()=>{for(const [id,d] of drafts)if(d.dirty)schedule(id,0);});
|
|
75
|
+
window.addEventListener('layout-saved',load);
|
|
76
|
+
window.addEventListener('beforeunload',e=>{if(dirty()){e.preventDefault();e.returnValue='';}});
|
|
77
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -10,3 +10,9 @@
|
|
|
10
10
|
.announcement-help{font-size:13px;color:#b9c1cd}#toast-feedback{color:#e8bf70;min-height:24px}
|
|
11
11
|
|
|
12
12
|
.turn-indicator{margin:16px 0;padding:18px;border:2px solid #c59b52;border-radius:12px;background:#172335;color:#fff}.turn-indicator strong{display:block;font-size:1.35rem}.turn-indicator p{margin:8px 0 0}#turn-reason{font-size:.9rem;opacity:.8}
|
|
13
|
+
[hidden]{display:none!important}.admin-tabs{display:flex;gap:12px;margin:24px 0}.control .roster-editor{margin:24px 0;padding:22px;border:1px solid #536171;border-radius:10px;background:#171e29}.roster-editor h3{margin:0;font-size:25px}.roster-meta{color:#dbc08b}.roster-editor a{color:#e8bf70}.roster-url{display:block;overflow-wrap:anywhere;margin-top:12px}.roster-toolbar,.unit-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.unit-tools strong{margin-right:auto}.control .unit-tools button{padding:8px 12px;font-size:13px}.unit-editors{list-style:none;padding:0}.unit-editor{padding:18px;margin:18px 0;border:1px solid #455469;border-radius:8px;background:#101620}.control .unit-editor textarea{display:block;width:100%;margin-top:6px;padding:11px;color:white;background:#202936;border:1px solid #536171;border-radius:5px;font:16px/1.4 Arial;resize:vertical}.unit-flags{display:flex;gap:24px}.control .unit-flags label{display:flex;align-items:center;gap:8px}.control .unit-flags input{width:20px;height:20px;margin:0;accent-color:#d9ad4a}#roster-feedback{position:sticky;bottom:8px;background:#1b2738;padding:12px;color:#f3d69f;border-radius:6px}#roster-feedback:empty{display:none}
|
|
14
|
+
|
|
15
|
+
.unit-fields{margin-top:16px}.unit-fields summary{color:#b8c7db;cursor:pointer;font-size:14px}.unit-tools strong{overflow-wrap:anywhere;max-width:100%}
|
|
16
|
+
.control .drag-handle{cursor:grab;padding:6px 10px;background:transparent;color:#e2bd78;font-size:26px;touch-action:none}.drag-handle:active{cursor:grabbing}.unit-editor.dragging{opacity:.4}.unit-editor.drag-over{outline:2px solid #d9ad4a;outline-offset:3px}
|
|
17
|
+
|
|
18
|
+
.drag-handle{touch-action:none;user-select:none}
|
package/rosters.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const {randomUUID}=require('node:crypto');
|
|
2
|
+
function linkPlayers(next,previous={}){
|
|
3
|
+
const used=new Set();const result={...next};
|
|
4
|
+
for(const side of ['left','right']){
|
|
5
|
+
const player=next[side];if(!player||typeof player!=='object')throw Error('Faltan los dos jugadores.');
|
|
6
|
+
const known=['left','right'].map(s=>previous[s]).filter(Boolean);
|
|
7
|
+
let id=known.find(p=>p.rosterId===player.rosterId)?.rosterId;
|
|
8
|
+
if(!id)id=known.find(p=>(p.appName||p.name)===(player.appName||player.name))?.rosterId;
|
|
9
|
+
if(!id)id=previous[side]?.rosterId;
|
|
10
|
+
if(!id||used.has(id))id=randomUUID();
|
|
11
|
+
used.add(id);result[side]={...player,rosterId:id};
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function validateUnits(input){
|
|
16
|
+
if(!Array.isArray(input)||input.length>80)throw Error('La lista admite hasta 80 unidades.');
|
|
17
|
+
const ids=new Set();
|
|
18
|
+
const text=(v,max,label,required=false)=>{if(typeof v!=='string'||v.length>max||(required&&!v.trim()))throw Error(`${label}: texto ${required?'obligatorio, ':''}máximo ${max} caracteres.`);return v.trim();};
|
|
19
|
+
return input.map(u=>{
|
|
20
|
+
if(!u||typeof u!=='object')throw Error('Unidad inválida.');
|
|
21
|
+
const id=u.id||randomUUID();if(typeof id!=='string'||!/^[a-zA-Z0-9_-]{1,80}$/.test(id)||ids.has(id))throw Error('Identificador de unidad inválido o repetido.');ids.add(id);
|
|
22
|
+
if(!Array.isArray(u.characters)||u.characters.length>8)throw Error('Cada unidad admite hasta 8 personajes.');
|
|
23
|
+
if(typeof u.deepStrike!=='boolean'||typeof u.destroyed!=='boolean')throw Error('En reservas y destruida deben ser true o false.');
|
|
24
|
+
if(u.battleshocked!==undefined&&typeof u.battleshocked!=='boolean')throw Error('Battleshocked debe ser true o false.');
|
|
25
|
+
return {id,name:text(u.name,100,'Unidad',true),enhancement:text(u.enhancement??'',100,'Mejora'),characters:u.characters.map(c=>text(c,80,'Personaje',true)),notes:text(u.notes??'',160,'Notas'),deepStrike:u.deepStrike,destroyed:u.destroyed,battleshocked:u.battleshocked===true};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function validateBackground(value=''){
|
|
29
|
+
if(typeof value!=='string'||value.length>2048)throw Error('URL de fondo inválida.');
|
|
30
|
+
if(!value.trim())return '';
|
|
31
|
+
let url;try{url=new URL(value);}catch{throw Error('Introduce una URL completa para el fondo.');}
|
|
32
|
+
if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw Error('El fondo debe usar HTTP o HTTPS, sin credenciales.');
|
|
33
|
+
return url.href;
|
|
34
|
+
}
|
|
35
|
+
function validateName(value=''){
|
|
36
|
+
if(typeof value!=='string'||value.length>80)throw Error('Nombre de lista: máximo 80 caracteres.');
|
|
37
|
+
return value.trim();
|
|
38
|
+
}
|
|
39
|
+
module.exports={linkPlayers,validateUnits,validateBackground,validateName};
|
package/scripts/tts-demo.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const {SpeechQueue}=require('../tts/queue');
|
|
2
2
|
const {sanitize}=require('../tts/sanitizer');
|
|
3
3
|
async function main(){
|
|
4
|
-
if(process.platform!=='darwin')throw Error('Esta prueba de voz requiere macOS.');
|
|
5
4
|
const q=new SpeechQueue();
|
|
6
5
|
for(const [author,text] of [['Pedro','Hola, esto es una prueba.'],['John','Este mensaje debe sonar después del anterior.'],['Pedro','Mira esto https://example.com/prueba']])q.enqueue(sanitize({author,text}));
|
|
7
6
|
const stop=()=>{q.stop();};process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
package/server.js
CHANGED
|
@@ -51,8 +51,17 @@ const defaultState = {
|
|
|
51
51
|
let state = defaultState;
|
|
52
52
|
try { state = { ...defaultState, ...JSON.parse(fs.readFileSync(statePath, 'utf8')) }; }
|
|
53
53
|
catch { /* First run: use the defaults. */ }
|
|
54
|
+
const {linkPlayers,validateUnits,validateBackground,validateName}=require('./rosters');
|
|
55
|
+
state=linkPlayers(state,state);
|
|
56
|
+
saveState();
|
|
57
|
+
const rostersPath=path.join(dataDir,'rosters.json');
|
|
58
|
+
let rosters={};
|
|
59
|
+
try{rosters=JSON.parse(fs.readFileSync(rostersPath,'utf8'));}catch{}
|
|
60
|
+
function rosterSnapshot(){return {players:{left:state.left,right:state.right},rosters};}
|
|
61
|
+
const rosterListeners=new Set();
|
|
62
|
+
function broadcastRosters(){const msg=`data: ${JSON.stringify(rosterSnapshot())}\n\n`;for(const res of rosterListeners)res.write(msg);}
|
|
54
63
|
const listeners = new Set();
|
|
55
|
-
function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); }
|
|
64
|
+
function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); broadcastRosters(); }
|
|
56
65
|
function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
|
|
57
66
|
function saveState() { const temporaryPath = `${statePath}.tmp`; fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2)); fs.renameSync(temporaryPath, statePath); }
|
|
58
67
|
|
|
@@ -69,11 +78,11 @@ const announcements = new Announcements({
|
|
|
69
78
|
});
|
|
70
79
|
const toastTimer = setInterval(() => { try { announcements.tick([...toastListeners.values()].includes('overlay')); } catch(e) { console.error('Mensajes: '+e.message); } }, 1000);
|
|
71
80
|
toastTimer.unref();
|
|
72
|
-
async function readToastBody(req) {
|
|
81
|
+
async function readToastBody(req,limit=8192) {
|
|
73
82
|
const chunks = []; let size = 0;
|
|
74
83
|
for await (const chunk of req) {
|
|
75
84
|
size += chunk.length;
|
|
76
|
-
if (size >
|
|
85
|
+
if (size > limit) throw new Error('El mensaje es demasiado largo.');
|
|
77
86
|
chunks.push(chunk);
|
|
78
87
|
}
|
|
79
88
|
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
@@ -82,6 +91,25 @@ async function readToastBody(req) {
|
|
|
82
91
|
const server = http.createServer(async (req, res) => {
|
|
83
92
|
res.setHeader('Cache-Control','no-store');
|
|
84
93
|
const url = new URL(req.url, 'http://localhost');
|
|
94
|
+
if(url.pathname==='/rosters/events' && req.method==='GET'){
|
|
95
|
+
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
96
|
+
rosterListeners.add(res);res.write(`data: ${JSON.stringify(rosterSnapshot())}\n\n`);
|
|
97
|
+
req.on('close',()=>rosterListeners.delete(res));return;
|
|
98
|
+
}
|
|
99
|
+
if(url.pathname==='/rosters' && req.method==='GET')return send(res,200,'application/json',JSON.stringify(rosterSnapshot()));
|
|
100
|
+
if(url.pathname.startsWith('/rosters/') && req.method==='POST'){
|
|
101
|
+
try{
|
|
102
|
+
const id=url.pathname.slice('/rosters/'.length);
|
|
103
|
+
if(![state.left.rosterId,state.right.rosterId].includes(id))return send(res,404,'application/json',JSON.stringify({error:'Jugador no encontrado. Recarga el panel.'}));
|
|
104
|
+
const input=await readToastBody(req,200000);
|
|
105
|
+
const old=rosters[id]||{revision:0,units:[]};
|
|
106
|
+
if(input.revision!==old.revision)return send(res,409,'application/json',JSON.stringify({error:'La lista cambió en otro panel. Recarga antes de guardar.'}));
|
|
107
|
+
const roster={revision:old.revision+1,name:validateName(input.name??old.name??''),units:validateUnits(input.units),backgroundUrl:validateBackground(input.backgroundUrl??old.backgroundUrl??'')};
|
|
108
|
+
const next={...rosters,[id]:roster};
|
|
109
|
+
fs.writeFileSync(rostersPath+'.tmp',JSON.stringify(next,null,2));fs.renameSync(rostersPath+'.tmp',rostersPath);rosters=next;
|
|
110
|
+
broadcastRosters();return send(res,200,'application/json',JSON.stringify(roster));
|
|
111
|
+
}catch(e){return send(res,400,'application/json',JSON.stringify({error:e.message}));}
|
|
112
|
+
}
|
|
85
113
|
if (url.pathname === '/toasts/events' && req.method === 'GET') {
|
|
86
114
|
res.writeHead(200, {'Content-Type':'text/event-stream', 'Cache-Control':'no-cache', Connection:'keep-alive'});
|
|
87
115
|
toastListeners.set(res, url.searchParams.get('client') === 'control' ? 'control' : 'overlay');
|
|
@@ -112,7 +140,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
112
140
|
if (url.pathname === '/state' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify(state));
|
|
113
141
|
if (url.pathname === '/state' && req.method === 'POST') {
|
|
114
142
|
let body = ''; req.on('data', c => body += c); req.on('end', () => {
|
|
115
|
-
try { state = JSON.parse(body); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
143
|
+
try { state = linkPlayers(JSON.parse(body),state); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
116
144
|
catch { send(res, 400, 'application/json', JSON.stringify({ error: 'Invalid data' })); }
|
|
117
145
|
}); return;
|
|
118
146
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fs=require('node:fs/promises'),path=require('node:path'),os=require('node:os');
|
|
2
|
+
const {createHash,randomUUID}=require('node:crypto');
|
|
3
|
+
const MODEL='onnx-community/Kokoro-82M-v1.0-ONNX';
|
|
4
|
+
const REVISION='1939ad2a8e416c0acfeecc08a694d14ef25f2231';
|
|
5
|
+
const ASSETS={
|
|
6
|
+
'onnx/model_quantized.onnx':'fbae9257e1e05ffc727e951ef9b9c98418e6d79f1c9b6b13bd59f5c9028a1478',
|
|
7
|
+
'voices/ef_dora.bin':'f66ec66bd295acb18372e37008533a9a3228483ccd294e7538d5d9294ac9a532',
|
|
8
|
+
'voices/em_alex.bin':'27809e9eafdcbcfff90a3016c697568676531de2a2c39cee29c96c7bd6b83e95',
|
|
9
|
+
'voices/em_santa.bin':'ad43b774e1ca24d05c6161297d8aeb770ac3d29bb95daf516727af5f7d543683',
|
|
10
|
+
'tokenizer.json':'77a02c8e164413299b4b4c403b14f8e0e1c1b727db4d46a09d6327b861060a34'
|
|
11
|
+
};
|
|
12
|
+
function cacheDirectory(){return process.env.KOKORO_CACHE_DIR||path.join(os.homedir(),'.cache','wargaming-overlay','kokoro',REVISION);}
|
|
13
|
+
const hash=b=>createHash('sha256').update(b).digest('hex');
|
|
14
|
+
async function asset(name,{cacheDir=cacheDirectory(),fetchImpl=fetch,signal,log=()=>{}}={}){
|
|
15
|
+
if(!Object.hasOwn(ASSETS,name))throw Error('Recurso Kokoro desconocido.');
|
|
16
|
+
const dest=path.join(cacheDir,name);
|
|
17
|
+
try{const b=await fs.readFile(dest);if(hash(b)===ASSETS[name])return b;}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
18
|
+
log(`[Kokoro] Descargando ${name} (solo la primera vez).`);
|
|
19
|
+
const response=await fetchImpl(`https://huggingface.co/${MODEL}/resolve/${REVISION}/${name}`,{signal:signal||AbortSignal.timeout(90000)});
|
|
20
|
+
if(!response.ok)throw Error(`Descarga Kokoro: HTTP ${response.status}.`);
|
|
21
|
+
const data=Buffer.from(await response.arrayBuffer());if(hash(data)!==ASSETS[name])throw Error('La descarga Kokoro no pasó la verificación de integridad.');
|
|
22
|
+
await fs.mkdir(path.dirname(dest),{recursive:true});const temporary=dest+'.'+randomUUID()+'.tmp';
|
|
23
|
+
try{await fs.writeFile(temporary,data);await fs.rename(temporary,dest);}finally{await fs.rm(temporary,{force:true});}
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
async function ensureAssets(options){const [model,tokenizer,...voices]=await Promise.all(['onnx/model_quantized.onnx','tokenizer.json','voices/ef_dora.bin','voices/em_alex.bin','voices/em_santa.bin'].map(name=>asset(name,options)));return {model,voices:Object.fromEntries(['ef_dora','em_alex','em_santa'].map((v,i)=>[v,voices[i]])),vocab:JSON.parse(tokenizer).model.vocab};}
|
|
27
|
+
module.exports={ensureAssets,asset,ASSETS,MODEL,REVISION,cacheDirectory};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const {ensureAssets}=require('./kokoro-assets');
|
|
2
|
+
let engine;
|
|
3
|
+
async function load(options={}){
|
|
4
|
+
if(!engine)engine=(async()=>{
|
|
5
|
+
const wasm=process.platform==='win32'||process.env.KOKORO_RUNTIME==='wasm';
|
|
6
|
+
const ort=require(wasm?'onnxruntime-web':'onnxruntime-node');
|
|
7
|
+
if(wasm)ort.env.wasm.numThreads=1;
|
|
8
|
+
const {model,voices,vocab}=await ensureAssets(options);
|
|
9
|
+
const {default:init}=await import('@echogarden/espeak-ng-emscripten');
|
|
10
|
+
const module=await init();const espeak=new module.eSpeakNGWorker();
|
|
11
|
+
const session=await ort.InferenceSession.create(model,{executionProviders:[wasm?'wasm':'cpu'],intraOpNumThreads:2});
|
|
12
|
+
return {ort,voices,vocab,espeak,session};
|
|
13
|
+
})().catch(e=>{engine=null;throw e;});
|
|
14
|
+
return engine;
|
|
15
|
+
}
|
|
16
|
+
async function generate(text,{voice='ef_dora',speed=1,...options}={}){
|
|
17
|
+
if(!['ef_dora','em_alex','em_santa'].includes(voice))throw Error('Voz española desconocida.');
|
|
18
|
+
if(!Number.isFinite(speed)||speed<0.5||speed>2)throw Error('kokoroSpeed debe estar entre 0.5 y 2.');
|
|
19
|
+
const {ort,voices,vocab,espeak,session}=await load(options);
|
|
20
|
+
espeak.set_voice('es');
|
|
21
|
+
// Preserve clause punctuation and map eSpeak IPA affricates to Kokoro's vocabulary.
|
|
22
|
+
const phones=String(text).normalize('NFC').split(/([:;,.!?])/).map(part=>/^[,:;.!?]$/.test(part)?part:espeak.synthesize_ipa(part.replace(/[<>\[\]]/g,'')).ipa.replaceAll('_','').trim().replace(/\n/g,' ')).join(' ').replaceAll('tʃ','ʧ').replaceAll('dʒ','ʤ').replace(/\u0361/g,'');
|
|
23
|
+
const tokens=Array.from(phones).filter(c=>vocab[c]!==undefined).map(c=>vocab[c]);
|
|
24
|
+
if(!tokens.length)throw Error('No hay texto pronunciable.');
|
|
25
|
+
if(tokens.length>509)throw Error('El texto excede el límite de Kokoro.');
|
|
26
|
+
const ids=[0,...tokens,0];const offset=tokens.length*256*4;
|
|
27
|
+
const bytes=voices[voice].subarray(offset,offset+1024);const style=new Float32Array(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.length));
|
|
28
|
+
const out=await session.run({input_ids:new ort.Tensor('int64',BigInt64Array.from(ids,BigInt),[1,ids.length]),style:new ort.Tensor('float32',style,[1,256]),speed:new ort.Tensor('float32',Float32Array.of(speed),[1])});
|
|
29
|
+
return {samples:out.waveform.data,sampleRate:24000};
|
|
30
|
+
}
|
|
31
|
+
module.exports={generate};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Isolate native synthesis/playback from the overlay. Killing this process cancels both.
|
|
2
|
+
const {generate}=require('./kokoro-engine');
|
|
3
|
+
const {play}=require('./playback');
|
|
4
|
+
let input='';process.stdin.setEncoding('utf8');
|
|
5
|
+
process.stdin.on('data',chunk=>{input+=chunk;if(input.length>20000)process.exit(1);});
|
|
6
|
+
process.stdin.on('end',async()=>{
|
|
7
|
+
try{
|
|
8
|
+
const {text,voice,speed,cacheDir}=JSON.parse(input);const start=performance.now();
|
|
9
|
+
const {samples,sampleRate}=await generate(text,{voice,speed,cacheDir,log:message=>console.error(message)});
|
|
10
|
+
console.error(`[Kokoro] Audio generado en ${((performance.now()-start)/1000).toFixed(2)} s.`);
|
|
11
|
+
if(process.platform==='win32')await require('./windows-playback').playWindows(samples,sampleRate);else await play(samples,sampleRate);process.exit(0);
|
|
12
|
+
}catch(e){console.error('[Kokoro] '+e.message);process.exit(1);}
|
|
13
|
+
});
|