Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole) - Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen ueber die neue Browser-Source /sounds - Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS - Browser-Sources melden, welche Seite sie sind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
// Die Sounds fuer das Pult.
|
|
//
|
|
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
|
|
// /sounds. Dazwischen liegt nur dieser Ordner:
|
|
//
|
|
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
|
|
// <db>/sounds/<id>.<endung> die Datei selbst
|
|
//
|
|
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
|
|
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
|
|
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
|
|
//
|
|
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
|
|
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
|
|
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
export type Sound = {
|
|
id: string;
|
|
name: string;
|
|
emoji: string;
|
|
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
|
|
farbe: string;
|
|
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
|
|
laut: number;
|
|
datei: string;
|
|
typ: string;
|
|
bytes: number;
|
|
seit: string;
|
|
};
|
|
|
|
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
|
|
|
|
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
|
|
export const MAX_BYTES = 8 * 1024 * 1024;
|
|
export const MAX_SOUNDS = 120;
|
|
|
|
const ENDUNG: Record<string, string> = {
|
|
'audio/mpeg': '.mp3',
|
|
'audio/wav': '.wav',
|
|
'audio/ogg': '.ogg',
|
|
'audio/webm': '.webm',
|
|
'audio/mp4': '.m4a',
|
|
};
|
|
|
|
/**
|
|
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
|
|
* Browser sicher abspielt.
|
|
*/
|
|
export function erkenneTyp(b: Buffer): string | null {
|
|
if (b.length < 12) return null;
|
|
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
|
|
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
|
|
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
|
|
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
|
|
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
|
|
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
|
|
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
|
|
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
|
|
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
|
|
return null;
|
|
}
|
|
|
|
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
|
|
export function nameAusDatei(datei: string): string {
|
|
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
|
|
return saeubern(ohne) || 'Sound';
|
|
}
|
|
|
|
function saeubern(s: string, max = 40): string {
|
|
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
|
|
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
|
|
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
|
|
.slice(0, max).join('');
|
|
}
|
|
|
|
function emojiOk(s: string): string {
|
|
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
|
|
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
|
|
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
|
|
}
|
|
|
|
export type Soundboard = {
|
|
liste: () => Sound[];
|
|
holen: (id: string) => Sound | undefined;
|
|
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
|
|
hinzufuegen: (name: string, daten: Buffer) => Sound;
|
|
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
|
|
entfernen: (id: string) => boolean;
|
|
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
|
|
verschieben: (id: string, um: number) => boolean;
|
|
};
|
|
|
|
export function createSoundboard(ordner: string): Soundboard {
|
|
const index = path.join(ordner, 'sounds.json');
|
|
const sounds: Sound[] = lesen();
|
|
|
|
function lesen(): Sound[] {
|
|
try {
|
|
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
|
|
return Array.isArray(roh.sounds) ? roh.sounds : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function schreiben(): void {
|
|
fs.mkdirSync(ordner, { recursive: true });
|
|
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
|
|
// bleibt die alte Liste ganz statt einer halben neuen.
|
|
const tmp = `${index}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
|
|
fs.renameSync(tmp, index);
|
|
}
|
|
|
|
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
|
|
|
|
return {
|
|
liste: () => sounds.map((s) => ({ ...s })),
|
|
holen,
|
|
|
|
datei(id) {
|
|
const s = holen(id);
|
|
if (!s) return undefined;
|
|
try {
|
|
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
hinzufuegen(name, daten) {
|
|
if (daten.length > MAX_BYTES) {
|
|
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
|
|
}
|
|
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
|
|
const typ = erkenneTyp(daten);
|
|
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
|
|
|
|
const id = crypto.randomBytes(6).toString('hex');
|
|
const datei = `${id}${ENDUNG[typ]}`;
|
|
fs.mkdirSync(ordner, { recursive: true });
|
|
fs.writeFileSync(path.join(ordner, datei), daten);
|
|
const s: Sound = {
|
|
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
|
|
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
|
|
};
|
|
sounds.push(s);
|
|
schreiben();
|
|
return { ...s };
|
|
},
|
|
|
|
aendern(id, was) {
|
|
const s = holen(id);
|
|
if (!s) return undefined;
|
|
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
|
|
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
|
|
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
|
|
if (was.laut !== undefined && Number.isFinite(was.laut)) {
|
|
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
|
|
}
|
|
schreiben();
|
|
return { ...s };
|
|
},
|
|
|
|
entfernen(id) {
|
|
const i = sounds.findIndex((s) => s.id === id);
|
|
if (i === -1) return false;
|
|
const [weg] = sounds.splice(i, 1);
|
|
schreiben();
|
|
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
|
|
return true;
|
|
},
|
|
|
|
verschieben(id, um) {
|
|
const i = sounds.findIndex((s) => s.id === id);
|
|
const ziel = i + Math.sign(um);
|
|
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
|
|
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
|
|
schreiben();
|
|
return true;
|
|
},
|
|
};
|
|
}
|