Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
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>
This commit is contained in:
1 parent
70448967f5
commit
8be195d938
29 files changed
+2935
-28
No files matched your search
@@ -0,0 +1,187 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user