// Die Sounds fuer das Pult. // // Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source // /sounds. Dazwischen liegt nur dieser Ordner: // // /sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge // /sounds/. 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 = { '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>) => 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; }, }; }