From 1154e7a6a575a5b7808bdadd9bf65530c33ce54f Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 14 Aug 2026 12:53:16 +0200 Subject: [PATCH] Radio: Lautstaerke im Panel -- und leise reinkommen statt erschrecken Der Anlass ist eine Beobachtung aus dem Betrieb: der Bot kam mit voller Aussteuerung in den Kanal, und wer schon drin sass, ist erschrocken. Dass sich jeder den Bot fuer sich leiser drehen kann, hilft erst *nachdem* es zu laut war. Also liegt die Vorgabe jetzt bei 50 %, und lauter machen kostet zwei Knopfdruecke. Dafuer musste die Tonkette umgebaut werden. Bisher lief Opus unveraendert durch (ffmpeg -> Ogg/Opus -> Discord). Das war eine Stufe sparsamer, aber an fertigen Opus-Paketen laesst sich die Lautstaerke nicht drehen -- dafuer muss man an die Abtastwerte. Jetzt: ffmpeg gibt PCM aus, die Sprachbibliothek regelt und kodiert. Der alte Einwand im Dateikopf ("sonst muesste @discordjs/opus mit ins Image, und das will gebaut werden") stimmt weiterhin -- fuer @discordjs/opus. Nachgemessen am 14.08.2026 in node:22-slim: @discordjs/opus npm i bricht ab, kein Prebuild fuer node-v127/glibc-2.36, node-gyp will bauen, im Image fehlen die Werkzeuge opusscript reines JavaScript, installiert in 740 ms, kein Compiler 0,3 % eines Kerns fuers Kodieren eines Dauerstroms Der Umweg ueber Opus-Dekodieren entfaellt dabei ganz, ffmpeg liefert ja schon Abtastwerte. Deshalb 0,3 % und nicht 2,9 %, was Dekodieren und Kodieren zusammen gekostet haetten. Die Kette ist mit echtem ffmpeg gegen einen Sinuston geprueft: 200 Opus-Pakete fuer 4,0 s Ton, kein Versatz, und die Lautstaerke laesst sich mitten im Strom aendern. Genau darum geht der Weg ueber PCM und nicht ueber ffmpegs volume-Filter -- der haette bei jedem Klick einen Neustart des Prozesses gebraucht, also eine hoerbare Luecke. Am Panel zwei Knoepfe in der bestehenden Reihe, Schritte von 10, Bereich 10 bis 200. Am Anschlag graut der Knopf aus, statt sich druecken zu lassen und nichts zu tun. Die Rueckmeldung ist die neu gezeichnete Tafel mit dem neuen Prozentwert. Der Wert steht in den Einstellungen, nicht in radio_state: der Merker dort wird beim Stoppen geloescht, die Lautstaerke soll das ueberleben. Dazu der Kanal-Status: was laeuft, steht jetzt auch in der Statuszeile des Sprachkanals und wird mit dem Titel nachgefuehrt. ACHTUNG, das ist ein UNDOKUMENTIERTER Endpunkt. PUT /channels/{id}/voice-status -- Discord hat ihn nie in die API-Doku aufgenommen, die Pull Requests dazu liegen seit 2023 offen, und discord.js hat die Umsetzung als "not planned" geschlossen. Roher Aufruf ueber client.rest also, und er kann ohne Ankuendigung verschwinden. Deshalb ist er als Beiwerk gebaut: faellt er aus, sagt er einmal warum und schweigt dann -- ein Fehler alle 30 Sekunden waere kein Hinweis mehr, sondern Rauschen. Das Radio laeuft in jedem Fall weiter. Der naechste Sender-Start versucht es erneut, falls das Recht inzwischen erteilt wurde. Das Recht heisst "Kanalstatus festlegen" und sitzt auf Bit 48. discord.js kennt es nicht einmal als Konstante, deshalb steht die Zahl im Quelltext. Ungeprueft, weil dafuer ein echter Sprachkanal noetig ist: ob das Recht dem Bot hier tatsaechlich erteilt ist. Faellt es aus, steht der Grund im Log. Beim Stoppen wird die Zeile geleert -- sonst behauptet sie stundenlang, es liefe ein Lied, das laengst vorbei ist. Nebenbei: prism-media wurde direkt importiert, stand aber nie in der package.json. Der Import ist mit dem Ogg-Auspacker weggefallen. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 9 +- package.json | 1 + src/bot/commands/radio.js | 7 +- src/bot/radio.js | 190 +++++++++++++++++++++++++++++++++----- src/runtime-settings.js | 23 +++++ 5 files changed, 201 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c00890..0e0316b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,11 @@ "fastify": "^5.10.0", "gamedig": "^5.3.3", "libsodium-wrappers": "^0.8.4", + "opusscript": "^0.0.8", "sharp": "^0.35.3" }, "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/@discordjs/builders": { @@ -2544,6 +2545,12 @@ "wrappy": "1" } }, + "node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", diff --git a/package.json b/package.json index 594d1d6..a261d2a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "fastify": "^5.10.0", "gamedig": "^5.3.3", "libsodium-wrappers": "^0.8.4", + "opusscript": "^0.0.8", "sharp": "^0.35.3" } } diff --git a/src/bot/commands/radio.js b/src/bot/commands/radio.js index 56da0c6..2e940d9 100644 --- a/src/bot/commands/radio.js +++ b/src/bot/commands/radio.js @@ -6,7 +6,9 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { moduleEnabled } from '../../modules.js'; import { listStations, getStation, setRadioState } from '../../db.js'; -import { spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden } from '../radio.js'; +import { + spielen, laeuft, tafelEmbed, tafelKomponenten, titelHolen, ffmpegVorhanden, titelMerken, +} from '../radio.js'; export const data = new SlashCommandBuilder() .setName('radio') @@ -90,8 +92,7 @@ export async function execute(interaction) { return; } ({ titel } = await titelHolen(station.url)); - const s = laeuft(interaction.guild.id); - if (s) s.titel = titel; + titelMerken(interaction.client, interaction.guild.id, titel); } const jetzt = laeuft(interaction.guild.id); diff --git a/src/bot/radio.js b/src/bot/radio.js index 29cec0e..6072cb8 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -5,10 +5,19 @@ // Extraktoren. Ein Icecast-Stream dagegen läuft einfach — er hat keine // Warteschlange, kein Ende und keine Rechtsabteilung, die ihn abschaltet. // -// Die Tonkette kommt ohne Opus-Bibliothek aus: ffmpeg holt das MP3 und gibt -// direkt Ogg/Opus aus, prism-media packt nur noch aus. Gemessen: 600 Pakete -// in fünf Sekunden. Sonst müsste @discordjs/opus mit ins Image, und das will -// gebaut werden. +// Die Tonkette: ffmpeg holt den Sender und gibt rohes PCM aus, die +// Sprachbibliothek regelt die Lautstärke und packt nach Opus. Vorher lief +// Opus unverändert durch (ffmpeg → Ogg/Opus → Discord), das war eine Stufe +// sparsamer — aber an unveränderten Opus-Paketen lässt sich die Lautstärke +// nicht drehen, dafür muss man an die Abtastwerte. +// +// Der alte Einwand gegen eine Opus-Bibliothek galt `@discordjs/opus`: das +// hat für Node 22/glibc 2.36 kein fertiges Paket und will gebaut werden +// (nachgemessen am 14.08.2026, `npm i` bricht in node:22-slim ab). +// `opusscript` ist reines JavaScript, installiert in 740 ms ohne Compiler +// und kostet gemessene 0,3 % eines Kerns fürs Kodieren eines Dauerstroms. +// Der Umweg über Opus-Dekodieren entfällt dabei ganz — ffmpeg liefert ja +// schon Abtastwerte. import { spawn } from 'node:child_process'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Events, MessageFlags, @@ -19,11 +28,14 @@ import { AudioPlayerStatus, VoiceConnectionStatus, NoSubscriberBehavior, entersState, getVoiceConnection, } from '@discordjs/voice'; -import prism from 'prism-media'; import { brandEmbed } from '../embeds.js'; -import { brandFooter } from '../runtime-settings.js'; +import { + brandFooter, radioLautstaerke, LAUT_MIN, LAUT_MAX, LAUT_SCHRITT, +} from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; -import { listStations, getStation, radioState, radioStates, setRadioState, clearRadioState } from '../db.js'; +import { + listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting, +} from '../db.js'; import { senderGrundstock } from './radio-sender.js'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; @@ -123,8 +135,12 @@ function tonquelle(url) { '-analyzeduration', '0', '-loglevel', 'error', '-i', url, '-vn', // manche Sender legen ein Coverbild bei - '-acodec', 'libopus', '-f', 'ogg', - '-ar', '48000', '-ac', '2', '-b:a', '96k', + // Rohe Abtastwerte statt Opus: nur daran lässt sich die Lautstärke + // drehen. 48 kHz/stereo/16 Bit ist genau das, was Discord erwartet — + // 192 kB/s durch eine Rohrleitung im selben Prozessbaum, das fällt + // nicht ins Gewicht. + '-f', 's16le', '-acodec', 'pcm_s16le', + '-ar', '48000', '-ac', '2', 'pipe:1', ], { stdio: ['ignore', 'pipe', 'pipe'] }); // Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen @@ -305,9 +321,85 @@ export function beitrittsDiagnose(schritte) { + 'Ursache offen.'; } +// ── Kanal-Status ────────────────────────────────────────────────────────── + +/** + * Was gerade läuft, in die Statuszeile des Sprachkanals schreiben. + * + * Der Endpunkt ist UNDOKUMENTIERT. Discord hat ihn nie in die API-Doku + * aufgenommen — die Pull Requests dazu liegen seit 2023 offen — und + * discord.js hat die Umsetzung ausdrücklich abgelehnt („closed as not + * planned"). Deshalb ein roher Aufruf, und deshalb ist damit zu rechnen, + * dass er eines Tages ohne Ankündigung verschwindet. + * + * Daraus folgt die Bauweise: der Status ist Beiwerk. Er darf das Radio unter + * keinen Umständen mitreissen, und wenn er nicht geht, sagt er einmal warum + * und schweigt dann. Ein Fehler alle 30 Sekunden im Log ist keine Meldung + * mehr, sondern Rauschen. + * + * Das nötige Recht heisst „Kanalstatus festlegen" und sitzt auf Bit 48. + * discord.js kennt es nicht einmal als Konstante, deshalb steht die Zahl hier. + */ +export const RECHT_KANALSTATUS = 1n << 48n; // 281474976710656 +let statusAus = null; // null = versuchen; sonst der Grund fürs Aufgeben + +async function kanalStatusSetzen(client, kanalId, text) { + if (statusAus || !client || !kanalId) return; + try { + // 500 Zeichen ist die Grenze; Liedtitel sind kürzer, aber ein Sender + // mit kaputten Metadaten soll den Aufruf nicht zum Absturz bringen. + await client.rest.put(`/channels/${kanalId}/voice-status`, { + body: { status: String(text).slice(0, 500) }, + }); + } catch (e) { + statusAus = e?.status === 403 + ? 'dem Bot fehlt das Recht „Kanalstatus festlegen" im Sprachkanal' + : `${e?.status ?? '?'} — ${e?.message ?? 'unbekannt'}`; + console.error(`[radio] Kanal-Status abgeschaltet: ${statusAus}. ` + + 'Das Radio läuft weiter, nur die Zeile am Kanal bleibt leer. ' + + 'Nächster Sender-Start versucht es erneut.'); + } +} + /** Läuft gerade etwas auf diesem Server? */ export const laeuft = (guildId) => laufend.get(guildId) ?? null; +/** + * Titel merken und an den Kanal schreiben. + * + * An einer Stelle, weil der Titel aus drei Richtungen kommt: beim Start über + * den Befehl, beim Umschalten über die Tafel und laufend über den Abgleich. + * Vorher wäre jede davon eine eigene Gelegenheit gewesen, den Status zu + * vergessen. + */ +export function titelMerken(client, guildId, titel) { + const s = laufend.get(guildId); + if (!s || !titel || titel === s.titel) return false; + s.titel = titel; + kanalStatusSetzen(client, s.channelId, `🎵 ${titel}`); + return true; +} + +/** + * Lautstärke ändern — sofort hörbar, ohne den Strom anzufassen. + * + * Genau dafür läuft die Kette über PCM: `setVolume` greift in den laufenden + * Datenstrom. Der Weg über ffmpeg (`-af volume=…`) hätte einen Neustart des + * Prozesses gebraucht, also bei jedem Klick eine hörbare Lücke. + * + * Gibt den tatsächlich gesetzten Wert zurück, nicht den gewünschten — wer am + * Anschlag dreht, soll das am Rückgabewert merken können. + */ +export function lautstaerkeSetzen(guildId, wunsch) { + const wert = Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(wunsch))); + setSetting('radio_lautstaerke', String(wert)); + // Läuft gerade etwas, greift es sofort; läuft nichts, gilt es beim + // nächsten Start — deshalb steht der Wert in den Einstellungen und nicht + // nur am laufenden Strom. + laufend.get(guildId)?.resource?.volume?.setVolume(wert / 100); + return wert; +} + /** Alles abräumen — Verbindung, Spieler, ffmpeg */ export function stoppen(guildId) { const s = laufend.get(guildId); @@ -315,6 +407,9 @@ export function stoppen(guildId) { s.player.stop(true); s.ffmpeg?.kill('SIGKILL'); laufend.delete(guildId); + // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es + // liefe ein Lied, der längst vorbei ist. Leerer Text räumt sie ab. + kanalStatusSetzen(s.client, s.channelId, ''); } getVoiceConnection(guildId)?.destroy(); } @@ -440,9 +535,13 @@ export async function spielen(kanal, station) { vorher?.ffmpeg?.kill('SIGKILL'); const ff = tonquelle(station.url); - const resource = createAudioResource(ff.stdout.pipe(new prism.opus.OggDemuxer()), { - inputType: StreamType.Opus, + const resource = createAudioResource(ff.stdout, { + inputType: StreamType.Raw, + inlineVolume: true, // ohne das gibt es kein `resource.volume` }); + // Gleich beim ersten Rahmen, nicht erst wenn jemand einen Knopf drückt: + // die Vorgabe ist leise, damit niemand erschrickt. + resource.volume?.setVolume(radioLautstaerke() / 100); player.play(resource); verbindung.subscribe(player); @@ -474,10 +573,15 @@ export async function spielen(kanal, station) { } const eintrag = { - player, ffmpeg: ff, stationId: station.id, channelId: kanal.id, - titel: null, seit: Date.now(), tonLaeuft: null, + player, ffmpeg: ff, resource, stationId: station.id, channelId: kanal.id, + titel: null, seit: Date.now(), tonLaeuft: null, client: kanal.client, }; laufend.set(guildId, eintrag); + // Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen + // erteilt. Bis der erste Liedtitel da ist, steht der Sendername dort — + // besser als eine leere Zeile, während schon Musik läuft. + statusAus = null; + kanalStatusSetzen(kanal.client, kanal.id, `📻 ${station.name}`); beobachten.then((ok) => { eintrag.tonLaeuft = ok; if (!ok) console.error(`[radio] kein Ton von ${station.name} — ffmpeg da? ${ffmpegDa}`); @@ -516,7 +620,7 @@ export function tafelKomponenten(stationen, aktivId) { .setOptions(new StringSelectMenuOptionBuilder().setLabel('—').setValue('leer')); return [ new ActionRowBuilder().addComponents(leer), - new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf()), + knopfReihe(), ]; } @@ -538,7 +642,7 @@ export function tafelKomponenten(stationen, aktivId) { })); return new ActionRowBuilder().addComponents(menu); }); - reihen.push(new ActionRowBuilder().addComponents(stoppKnopf(), aktualisierenKnopf())); + reihen.push(knopfReihe()); return reihen; } @@ -546,6 +650,31 @@ const stoppKnopf = () => new ButtonBuilder() .setCustomId('radio_stop').setLabel('Stopp').setEmoji('⏹️').setStyle(ButtonStyle.Danger); const aktualisierenKnopf = () => new ButtonBuilder() .setCustomId('radio_refresh').setLabel('Aktualisieren').setEmoji('🔄').setStyle(ButtonStyle.Secondary); +const leiserKnopf = (amAnschlag) => new ButtonBuilder() + .setCustomId('radio_leiser').setLabel('Leiser').setEmoji('🔉') + .setStyle(ButtonStyle.Secondary).setDisabled(amAnschlag); +const lauterKnopf = (amAnschlag) => new ButtonBuilder() + .setCustomId('radio_lauter').setLabel('Lauter').setEmoji('🔊') + .setStyle(ButtonStyle.Secondary).setDisabled(amAnschlag); + +/** + * Die Knopfreihe — immer die fünfte und letzte, deshalb höchstens fünf Knöpfe. + * + * Am Anschlag wird der Knopf ausgegraut statt stumm nichts zu tun: ein Knopf, + * der sich drücken lässt und nichts bewirkt, sieht aus wie ein kaputter Knopf. + */ +function knopfReihe() { + const laut = radioLautstaerke(); + return new ActionRowBuilder().addComponents( + leiserKnopf(laut <= LAUT_MIN), + lauterKnopf(laut >= LAUT_MAX), + stoppKnopf(), + aktualisierenKnopf(), + ); +} + +/** 30 % ist leise, 100 % ist voll aufgedreht — das Zeichen sagt es vorab */ +const lautText = (p) => `${p <= 30 ? '🔈' : p <= 100 ? '🔉' : '🔊'} **${p} %**`; /** Wie viele Sender passen überhaupt in eine Tafel? */ export const TAFEL_GRENZE = JE_MENU * MAX_MENUS; @@ -555,8 +684,10 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { .setTitle('📻 Radio') .setFooter({ text: brandFooter('RADIO') }) .setTimestamp(); + const laut = radioLautstaerke(); if (!station) { - return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.'); + return embed.setDescription('Nichts läuft gerade. Sender unten auswählen.\n' + + `Startet mit ${lautText(laut)} — vorab einstellbar.`); } embed.setDescription(`**${station.name}**${kanalId ? ` in <#${kanalId}>` : ''}`); @@ -571,10 +702,13 @@ export function tafelEmbed(client, station, titel, kanalId, stand = {}) { }); return embed; } - embed.addFields({ - name: 'Läuft gerade', - value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_', - }); + embed.addFields( + { + name: 'Läuft gerade', + value: titel ? `🎵 ${titel}`.slice(0, 1024) : '_wird gerade abgefragt_', + }, + { name: 'Lautstärke', value: lautText(laut), inline: true }, + ); return embed; } @@ -624,8 +758,9 @@ export function startRadio(client) { const station = getStation(s.stationId); if (!station) continue; const { titel } = await titelHolen(station.url); - if (titel && titel !== s.titel) { - s.titel = titel; + // Nur bei echter Änderung neu zeichnen — und nur dann den + // Kanal-Status anfassen. Discord begrenzt beides. + if (titelMerken(client, guildId, titel)) { await tafelAktualisieren(client, guildId); } } @@ -679,7 +814,8 @@ export function startRadio(client) { export async function handleRadioInteraction(interaction) { const { customId } = interaction; const istAuswahl = customId.startsWith('radio_select:'); - if (!istAuswahl && !['radio_stop', 'radio_refresh'].includes(customId)) return false; + const knoepfe = ['radio_stop', 'radio_refresh', 'radio_leiser', 'radio_lauter']; + if (!istAuswahl && !knoepfe.includes(customId)) return false; const guildId = interaction.guild?.id; const eigenerKanal = interaction.member?.voice?.channel; @@ -687,6 +823,11 @@ export async function handleRadioInteraction(interaction) { if (customId === 'radio_stop') { stoppen(guildId); clearRadioState(guildId); + } else if (customId === 'radio_leiser' || customId === 'radio_lauter') { + // Die Rückmeldung ist die neu gezeichnete Tafel am Ende dieser + // Funktion: neuer Prozentwert, und am Anschlag graut der Knopf aus. + const schritt = customId === 'radio_lauter' ? LAUT_SCHRITT : -LAUT_SCHRITT; + lautstaerkeSetzen(guildId, radioLautstaerke() + schritt); } else if (istAuswahl) { const station = getStation(Number(interaction.values[0])); // Wer nicht im Sprachkanal sitzt, soll den anderen nicht den Sender @@ -711,8 +852,7 @@ export async function handleRadioInteraction(interaction) { }); // Den Titel gleich mitholen, sonst steht 30 Sekunden „wird abgefragt" const { titel } = await titelHolen(station.url); - const s = laufend.get(guildId); - if (s) s.titel = titel; + titelMerken(interaction.client, guildId, titel); } const jetzt = laufend.get(guildId); diff --git a/src/runtime-settings.js b/src/runtime-settings.js index ae45879..0851ba9 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -342,3 +342,26 @@ export function panelUrl() { export function panelZeichen() { return getSetting('panel_zeichen') || ''; } + +/** + * Lautstärke des Radios, in Prozent. + * + * Die Vorgabe ist 50 und nicht 100, und das ist eine Beobachtung aus dem + * Betrieb, keine Geschmacksfrage: mit voller Aussteuerung kommt der Bot in + * den Kanal, und wer schon drin sitzt, erschrickt. Jeder kann ihn zwar für + * sich selbst leiser drehen — aber erst *nachdem* es zu laut war. Also liegt + * die Vorgabe auf der leisen Seite; lauter machen kostet zwei Knopfdrücke. + * + * Bewusst hier und nicht in `radio_state`: der Merker dort wird beim Stoppen + * gelöscht, die eingestellte Lautstärke soll das überleben. + */ +export const LAUT_MIN = 10; +export const LAUT_MAX = 200; +export const LAUT_SCHRITT = 10; +export const LAUT_VORGABE = 50; + +export function radioLautstaerke() { + const roh = Number(getSetting('radio_lautstaerke')); + if (!Number.isFinite(roh)) return LAUT_VORGABE; + return Math.min(LAUT_MAX, Math.max(LAUT_MIN, Math.round(roh))); +}