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))); +}