diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0aa64ce..94acaf81f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ Each release usually includes various fixes and improvements. The most noteworthy of these, as well as any features and breaking changes, are listed here. +## 3.4 +* New filters system +* Deprecation of `TrackExceptionEvent.error`, replaced by `TrackExceptionEvent.exception` +* Added the `connected` boolean to player updates. +* Updated lavaplayer, fixes Soundcloud +* Added source name to REST api track objects +* Clients are now requested to make their name known during handshake + +Contributors: +[@freyacodes](https://github.com/freyacodes), +[@duncte123](https://github.com/duncte123), +[@DaliborTrampota](https://github.com/DaliborTrampota), +[@Mandruyd](https://github.com/Mandruyd), +[@Allvaa](https://github.com/@Allvaa), and +[@TopiSenpai](https://github.com/TopiSenpai) + ## 3.3.2.5 * Update Lavaplayer to 1.3.76 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 84c49f353..57a9a5eab 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -6,6 +6,14 @@ The Java client has support for JDA, but can also be adapted to work with other * You must be able to send messages via a shard's mainWS connection. * You must be able to intercept voice server updates from mainWS on your shard connection. +## Significant changes v3.3 -> v3.4 +* Added filters +* The `error` string on the `TrackExceptionEvent` has been deprecated and replaced by +the `exception` object following the same structure as the `LOAD_FAILED` error on [`/loadtracks`](#rest-api) +* Added the `connected` boolean to player updates. +* Added source name to REST api track objects +* Clients are now requested to make their name known during handshake + ## Significant changes v2.0 -> v3.0 * The response of `/loadtracks` has been completely changed (again since the initial v3.0 pre-release). * Lavalink v3.0 now reports its version as a handshake response header. @@ -41,8 +49,8 @@ the JDA client takes advantage of JDA's websocket write thread to send OP 4s for When opening a websocket connection, you must supply 3 required headers: ``` Authorization: Password matching the server config -Num-Shards: Total number of shards your bot is operating on User-Id: The user id of the bot you are playing music with +Client-Name: The name of your client. Optionally in the format NAME/VERSION ``` ### Outgoing messages @@ -81,7 +89,7 @@ If `pause` is set to true, the playback will be paused. This is an optional fiel "endTime": "120000", "volume": "100", "noReplace": false, - "pause": false, + "pause": false } ``` @@ -128,23 +136,96 @@ Volume may range from 0 to 1000. 100 is default. } ``` -#### Using the player equalizer +#### Using filters -There are 15 bands (0-14) that can be changed. -`gain` is the multiplier for the given band. The default value is 0. Valid values range from -0.25 to 1.0, -where -0.25 means the given band is completely muted, and 0.25 means it is doubled. Modifying the gain could -also change the volume of the output. +The `filters` op sets the filters. All the filters are optional, and leaving them out of this message will disable them. -```json +Adding a filter can have adverse effects on performance. These filters force Lavaplayer to decode all audio to PCM, +even if the input was already in the Opus format that Discord uses. This means decoding and encoding audio that would +normally require very little processing. This is often the case with YouTube videos. + +JSON comments are for illustration purposes only, and will not be accepted by the server. + +Note that filters may take a moment to apply. + +```yaml { - "op": "equalizer", + "op": "filters", "guildId": "...", - "bands": [ + + // Float value where 1.0 is 100%. Values >1.0 may cause clipping + "volume": 1.0, + + // There are 15 bands (0-14) that can be changed. + // "gain" is the multiplier for the given band. The default value is 0. Valid values range from -0.25 to 1.0, + // where -0.25 means the given band is completely muted, and 0.25 means it is doubled. Modifying the gain could + // also change the volume of the output. + "equalizer": [ { "band": 0, "gain": 0.2 } - ] + ], + + // Uses equalization to eliminate part of a band, usually targeting vocals. + "karaoke": { + "level": 1.0, + "monoLevel": 1.0, + "filterBand": 220.0, + "filterWidth": 100.0 + }, + + // Changes the speed, pitch, and rate. All default to 1. + "timescale": { + "speed": 1.0, + "pitch": 1.0, + "rate": 1.0 + }, + + // Uses amplification to create a shuddering effect, where the volume quickly oscillates. + // Example: https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv + "tremolo": { + "frequency": 2.0, // 0 < x + "depth": 0.5 // 0 < x ≤ 1 + }, + + // Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. + "vibrato": { + "frequency": 2.0, // 0 < x ≤ 14 + "depth": 0.5 // 0 < x ≤ 1 + }, + + // Rotates the sound around the stereo channels/user headphones aka Audio Panning. It can produce an effect similar to: https://youtu.be/QB9EB8mTKcc (without the reverb) + "rotation": { + "rotationHz": 0 // The frequency of the audio rotating around the listener in Hz. 0.2 is similar to the example video above. + }, + + // Distortion effect. It can generate some pretty unique audio effects. + "distortion": { + "sinOffset": 0, + "sinScale": 1, + "cosOffset": 0, + "cosScale": 1, + "tanOffset": 0, + "tanScale": 1, + "offset": 0, + "scale": 1 + } + + // Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. + // With the defaults, both channels are kept independent from each other. + // Setting all factors to 0.5 means both channels get the same audio. + "channelMix": { + "leftToLeft": 1.0, + "leftToRight": 0.0, + "rightToLeft": 0.0, + "rightToRight": 1.0, + } + + // Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. + "lowPass": { + "smoothing": 20.0 + } } ``` @@ -165,14 +246,18 @@ and you can send the same VOICE_SERVER_UPDATE to a new node. See [LavalinkSocket.java](https://github.com/freyacodes/lavalink-client/blob/master/src/main/java/lavalink/client/io/LavalinkSocket.java) for client implementation -Position information about a player. Includes unix timestamp. +This event includes: +* Unix timestamp in milliseconds. +* Track position in milliseconds. Omitted if not playing anything. +* `connected` is true when connected to the voice gateway. ```json { "op": "playerUpdate", "guildId": "...", "state": { "time": 1500467109, - "position": 60000 + "position": 60000, + "connected": true } } ``` @@ -246,9 +331,14 @@ private void handleEvent(JSONObject json) throws IOException { ); break; case "TrackExceptionEvent": + JSONObject jsonEx = json.getJSONObject("exception"); event = new TrackExceptionEvent(player, LavalinkUtil.toAudioTrack(json.getString("track")), - new RemoteTrackException(json.getString("error")) + new FriendlyException( + jsonEx.getString("message"), + FriendlyException.Severity.valueOf(jsonEx.getString("severity")), + new RuntimeException(jsonEx.getString("cause")) + ) ); break; case "TrackStuckEvent": @@ -308,7 +398,8 @@ Response: "isStream": false, "position": 0, "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" } } ] @@ -371,7 +462,8 @@ Response: "isStream": false, "position": 0, "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" } ``` @@ -403,7 +495,8 @@ Response: "isStream": false, "position": 0, "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" } }, ... @@ -558,7 +651,7 @@ queue is then emptied and the events are then replayed. ``` # Common pitfalls -Admidtedly Lavalink isn't inherently the most intuitive thing ever, and people tend to run into the same mistakes over again. Please double check the following if you run into problems developing your client and you can't connect to a voice channel or play audio: +Admittedly Lavalink isn't inherently the most intuitive thing ever, and people tend to run into the same mistakes over again. Please double check the following if you run into problems developing your client and you can't connect to a voice channel or play audio: 1. Check that you are forwarding sendWS events to **Discord**. 2. Check that you are intercepting **VOICE_SERVER_UPDATE**s to **Lavalink**. Do not edit the event object from Discord. diff --git a/LavalinkServer/application.yml.example b/LavalinkServer/application.yml.example index 4788332d8..c9133715a 100644 --- a/LavalinkServer/application.yml.example +++ b/LavalinkServer/application.yml.example @@ -12,7 +12,8 @@ lavalink: vimeo: true http: true local: false - bufferDurationMs: 400 + bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses + frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered youtubePlaylistLoadLimit: 6 # Number of pages at 100 each playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds youtubeSearchEnabled: true diff --git a/LavalinkServer/build.gradle b/LavalinkServer/build.gradle index 783f666ed..e30fba931 100644 --- a/LavalinkServer/build.gradle +++ b/LavalinkServer/build.gradle @@ -39,8 +39,12 @@ dependencies { exclude group: "org.slf4j", module: "slf4j-api" } compile group: 'moe.kyokobot.koe', name: 'ext-udpqueue', version: koeVersion - compile group: 'com.sedmelluq', name: 'lavaplayer', version: lavaplayerVersion - compile group: 'com.sedmelluq', name: 'lavaplayer-ext-youtube-rotator', version: lavaplayerIpRotatorVersion + compile group: 'com.github.walkyst', name: 'lavaplayer-fork', version: '1.3.96' + //compile group: 'com.sedmelluq', name: 'lavaplayer', version: lavaplayerVersion + compile(group: 'com.sedmelluq', name: 'lavaplayer-ext-youtube-rotator', version: lavaplayerIpRotatorVersion) { + exclude group: 'com.sedmelluq', module: 'lavaplayer' + } + compile group: 'com.github.natanbc', name: 'lavadsp', version: lavaDspVersion compile group: 'org.jetbrains.kotlin', name: 'kotlin-reflect', version: kotlinVersion compile group: 'org.springframework', name: 'spring-websocket', version: springWebSocketVersion @@ -48,6 +52,7 @@ dependencies { compile group: 'io.sentry', name: 'sentry-logback', version: sentryLogbackVersion compile group: 'com.github.oshi', name: 'oshi-core', version: oshiVersion compile group: 'org.json', name: 'json', version: jsonOrgVersion + compile group: 'com.google.code.gson', name: 'gson', version: gsonVersion compile(group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: springBootVersion) { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' } diff --git a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt index 953fb1a2b..c5a59b639 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt @@ -8,7 +8,7 @@ import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioSourceManager import com.sedmelluq.discord.lavaplayer.source.soundcloud.DefaultSoundCloudDataReader import com.sedmelluq.discord.lavaplayer.source.soundcloud.DefaultSoundCloudFormatHandler -import com.sedmelluq.discord.lavaplayer.source.soundcloud.DefaultSoundCloudHtmlDataLoader +import com.sedmelluq.discord.lavaplayer.source.soundcloud.DefaultSoundCloudDataLoader import com.sedmelluq.discord.lavaplayer.source.soundcloud.DefaultSoundCloudPlaylistLoader import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager @@ -45,6 +45,17 @@ class AudioPlayerConfiguration { audioPlayerManager.enableGcMonitoring() } + val defaultFrameBufferDuration = audioPlayerManager.frameBufferDuration + serverConfig.frameBufferDurationMs?.let { + if (it < 200) { // At the time of writing, LP enforces a minimum of 200ms. + log.warn("Buffer size of {}ms is illegal. Defaulting to {}", it, defaultFrameBufferDuration) + } + + val bufferDuration = it.takeIf { it >= 200 } ?: defaultFrameBufferDuration + log.debug("Setting frame buffer duration to {}", bufferDuration) + audioPlayerManager.frameBufferDuration = bufferDuration + } + if (sources.isYoutube) { val youtube = YoutubeAudioSourceManager(serverConfig.isYoutubeSearchEnabled) if (routePlanner != null) { @@ -62,15 +73,15 @@ class AudioPlayerConfiguration { } if (sources.isSoundcloud) { val dataReader = DefaultSoundCloudDataReader(); - val htmlDataLoader = DefaultSoundCloudHtmlDataLoader(); + val dataLoader = DefaultSoundCloudDataLoader(); val formatHandler = DefaultSoundCloudFormatHandler(); audioPlayerManager.registerSourceManager(SoundCloudAudioSourceManager( serverConfig.isSoundcloudSearchEnabled, dataReader, - htmlDataLoader, + dataLoader, formatHandler, - DefaultSoundCloudPlaylistLoader(htmlDataLoader, dataReader, formatHandler) + DefaultSoundCloudPlaylistLoader(dataLoader, dataReader, formatHandler) )); } if (sources.isBandcamp) audioPlayerManager.registerSourceManager(BandcampAudioSourceManager()) diff --git a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java index b4e420d94..e7c6e0f8f 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java +++ b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java @@ -31,6 +31,7 @@ public SentryConfiguration(ServerConfig serverConfig, SentryConfigProperties sen boolean warnDeprecatedDsnConfig = false; if (dsn == null || dsn.isEmpty()) { //try deprecated config location + //noinspection deprecation dsn = serverConfig.getSentryDsn(); warnDeprecatedDsnConfig = true; } diff --git a/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt index 7b33d94c8..d883490e6 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt @@ -32,6 +32,7 @@ class ServerConfig { @get:Deprecated("use {@link SentryConfigProperties} instead.") var sentryDsn = "" var bufferDurationMs: Int? = null + var frameBufferDurationMs: Int? = null var youtubePlaylistLoadLimit: Int? = null var playerUpdateInterval: Int? = 5 var isGcWarnings = true diff --git a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt index 8e703b3fb..b15dd5a43 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt @@ -1,7 +1,6 @@ package lavalink.server.io import lavalink.server.config.ServerConfig -import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus @@ -10,7 +9,6 @@ import org.springframework.http.server.ServerHttpResponse import org.springframework.stereotype.Controller import org.springframework.web.socket.WebSocketHandler import org.springframework.web.socket.server.HandshakeInterceptor -import java.util.Objects @Controller class HandshakeInterceptorImpl @Autowired diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt index 03a6483ff..c20e94b74 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt @@ -37,6 +37,7 @@ import org.json.JSONObject import org.slf4j.LoggerFactory import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.adapter.standard.StandardWebSocketSession +import java.net.InetSocketAddress import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue @@ -91,6 +92,8 @@ class SocketContext internal constructor( } } + internal fun getPlayer(guildId: Long) = getPlayer(guildId.toString()) + internal fun getPlayer(guildId: String) = players.computeIfAbsent(guildId) { Player(this, guildId, audioPlayerManager, serverConfig) } @@ -102,11 +105,12 @@ class SocketContext internal constructor( /** * Gets or creates a voice connection */ - fun getVoiceConnection(guild: Long): VoiceConnection { - var conn = koe.getConnection(guild) + fun getVoiceConnection(player: Player): VoiceConnection { + val guildId = player.guildId.toLong() + var conn = koe.getConnection(guildId) if (conn == null) { - conn = koe.createConnection(guild) - conn.registerListener(EventHandler(guild.toString())) + conn = koe.createConnection(guildId) + conn.registerListener(EventHandler(player)) } return conn } @@ -178,17 +182,23 @@ class SocketContext internal constructor( koe.close() } - private inner class EventHandler(private val guildId: String) : KoeEventAdapter() { + private inner class EventHandler(private val player: Player) : KoeEventAdapter() { override fun gatewayClosed(code: Int, reason: String?, byRemote: Boolean) { val out = JSONObject() out.put("op", "event") out.put("type", "WebSocketClosedEvent") - out.put("guildId", guildId) + out.put("guildId", player.guildId) out.put("reason", reason ?: "") out.put("code", code) out.put("byRemote", byRemote) send(out) + + SocketServer.sendPlayerUpdate(this@SocketContext, player) + } + + override fun gatewayReady(target: InetSocketAddress?, ssrc: Int) { + SocketServer.sendPlayerUpdate(this@SocketContext, player) } } } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt index 6fcd9decd..14285c979 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt @@ -34,7 +34,6 @@ import org.springframework.web.socket.CloseStatus import org.springframework.web.socket.TextMessage import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.handler.TextWebSocketHandler -import java.util.* import java.util.concurrent.ConcurrentHashMap @Service @@ -45,7 +44,6 @@ class SocketServer( ) : TextWebSocketHandler() { // userId <-> shardCount - private val shardCounts = ConcurrentHashMap() val contextMap = ConcurrentHashMap() @Suppress("LeakingThis") private val handlers = WebSocketHandlers(contextMap) @@ -57,10 +55,14 @@ class SocketServer( fun sendPlayerUpdate(socketContext: SocketContext, player: Player) { val json = JSONObject() + + val state = player.state + val connected = socketContext.getVoiceConnection(player).gatewayConnection?.isOpen == true + state.put("connected", connected) + json.put("op", "playerUpdate") json.put("guildId", player.guildId) - json.put("state", player.state) - + json.put("state", state) socketContext.send(json) } } @@ -69,11 +71,10 @@ class SocketServer( get() = contextMap.values override fun afterConnectionEstablished(session: WebSocketSession) { - val shardCount = Integer.parseInt(session.handshakeHeaders.getFirst("Num-Shards")!!) val userId = session.handshakeHeaders.getFirst("User-Id")!! val resumeKey = session.handshakeHeaders.getFirst("Resume-Key") - - shardCounts[userId] = shardCount + val clientName = session.handshakeHeaders.getFirst("Client-Name") + val userAgent = session.handshakeHeaders.getFirst("User-Agent") var resumable: SocketContext? = null if (resumeKey != null) resumable = resumableSessions.remove(resumeKey) @@ -85,8 +86,6 @@ class SocketServer( return } - shardCounts[userId] = shardCount - contextMap[session.id] = SocketContext( audioPlayerManager, serverConfig, @@ -95,7 +94,18 @@ class SocketServer( userId, koe.newClient(userId.toLong()) ) - log.info("Connection successfully established from " + session.remoteAddress!!) + + if (clientName != null) { + log.info("Connection successfully established from $clientName") + return + } + + log.info("Connection successfully established") + if (userAgent != null) { + log.warn("Library developers: Please specify a 'Client-Name' header. User agent: $userAgent") + } else { + log.warn("Library developers: Please specify a 'Client-Name' header.") + } } override fun afterConnectionClosed(session: WebSocketSession?, status: CloseStatus?) { @@ -157,6 +167,7 @@ class SocketServer( "destroy" -> handlers.destroy(context, json) "configureResuming" -> handlers.configureResuming(context, json) "equalizer" -> handlers.equalizer(context, json) + "filters" -> handlers.filters(context, json.getString("guildId"), message.payload) else -> log.warn("Unexpected operation: " + json.getString("op")) // @formatter:on } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java index 30c199d2b..8ea419f87 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java +++ b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java @@ -41,6 +41,9 @@ public class StatsTask implements Runnable { private final SocketServer socketServer; private final SystemInfo si = new SystemInfo(); + private final HardwareAbstractionLayer hal = si.getHardware(); + /** CPU ticks used for calculations in CPU load. */ + private long[] prevTicks; StatsTask(SocketContext context, SocketServer socketServer) { this.context = context; @@ -82,13 +85,17 @@ private void sendStats() { mem.put("reservable", Runtime.getRuntime().maxMemory()); out.put("memory", mem); - HardwareAbstractionLayer hal = si.getHardware(); - - JSONObject cpu = new JSONObject(); cpu.put("cores", Runtime.getRuntime().availableProcessors()); - cpu.put("systemLoad", hal.getProcessor().getSystemCpuLoad()); + // prevTicks will be null so set it to a value. + if(prevTicks == null) { + prevTicks = hal.getProcessor().getSystemCpuLoadTicks(); + } + // Compare current CPU ticks with previous to establish a CPU load and return double. + cpu.put("systemLoad", hal.getProcessor().getSystemCpuLoadBetweenTicks(prevTicks)); + // Set new prevTicks to current value for more accurate baseline, and checks in next schedule. + prevTicks = hal.getProcessor().getSystemCpuLoadTicks(); double load = getProcessRecentCpuUsage(); if (!Double.isFinite(load)) load = 0; cpu.put("lavalinkLoad", load); diff --git a/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt index 9080c566c..070eead2b 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt @@ -1,5 +1,7 @@ package lavalink.server.io +import lavalink.server.player.filters.Band +import lavalink.server.player.filters.FilterChain import com.sedmelluq.discord.lavaplayer.track.TrackMarker import lavalink.server.player.TrackEndMarkerHandler import lavalink.server.util.Util @@ -14,6 +16,9 @@ class WebSocketHandlers(private val contextMap: Map) { private val log: Logger = LoggerFactory.getLogger(WebSocketHandlers::class.java) } + private var loggedVolumeDeprecationWarning = false + private var loggedEqualizerDeprecationWarning = false + fun voiceUpdate(context: SocketContext, json: JSONObject) { val sessionId = json.getString("sessionId") val guildId = json.getLong("guildId") @@ -25,7 +30,10 @@ class WebSocketHandlers(private val contextMap: Map) { //discord sometimes send a partial server update missing the endpoint, which can be ignored. endpoint ?: return - context.getVoiceConnection(guildId).connect(VoiceServerInfo(sessionId, endpoint, token)) + val player = context.getPlayer(guildId) + val conn = context.getVoiceConnection(player) + conn.connect(VoiceServerInfo(sessionId, endpoint, token)) + player.provideTo(conn) } fun play(context: SocketContext, json: JSONObject) { @@ -45,7 +53,13 @@ class WebSocketHandlers(private val contextMap: Map) { player.setPause(json.optBoolean("pause", false)) if (json.has("volume")) { - player.setVolume(json.getInt("volume")) + if(!loggedVolumeDeprecationWarning) log.warn("The volume property in the play operation has been deprecated" + + "and will be removed in v4. Please configure a filter instead. Note that the new filter takes a " + + "float value with 1.0 being 100%") + loggedVolumeDeprecationWarning = true + val filters = player.filters ?: FilterChain() + filters.volume = json.getFloat("volume") / 100 + player.filters = filters } if (json.has("endTime")) { @@ -59,7 +73,7 @@ class WebSocketHandlers(private val contextMap: Map) { player.play(track) - val conn = context.getVoiceConnection(player.guildId.toLong()) + val conn = context.getVoiceConnection(player) context.getPlayer(json.getString("guildId")).provideTo(conn) } @@ -86,13 +100,25 @@ class WebSocketHandlers(private val contextMap: Map) { } fun equalizer(context: SocketContext, json: JSONObject) { + if (!loggedEqualizerDeprecationWarning) log.warn("The 'equalizer' op has been deprecated in favour of the " + + "'filters' op. Please switch to use that one, as this op will get removed in v4.") + loggedEqualizerDeprecationWarning = true + val player = context.getPlayer(json.getString("guildId")) - val bands = json.getJSONArray("bands") - for (i in 0 until bands.length()) { - val band = bands.getJSONObject(i) - player.setBandGain(band.getInt("band"), band.getFloat("gain")) + val list = mutableListOf() + json.getJSONArray("bands").forEach { b -> + val band = b as JSONObject + list.add(Band(band.getInt("band"), band.getFloat("gain"))) } + val filters = player.filters ?: FilterChain() + filters.equalizer = list + player.filters = filters + } + + fun filters(context: SocketContext, guildId: String, json: String) { + val player = context.getPlayer(guildId) + player.filters = FilterChain.parse(json) } fun destroy(context: SocketContext, json: JSONObject) { diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java index f58d0f5e4..14aa32500 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java @@ -72,7 +72,8 @@ private JSONObject trackToJSON(AudioTrack audioTrack) { .put("uri", trackInfo.uri) .put("isStream", trackInfo.isStream) .put("isSeekable", audioTrack.isSeekable()) - .put("position", audioTrack.getPosition()); + .put("position", audioTrack.getPosition()) + .put("sourceName", audioTrack.getSourceManager() == null ? null : audioTrack.getSourceManager().getSourceName()); } private JSONObject encodeLoadResult(LoadResult result) { diff --git a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java index 7931d75c8..b27871540 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java @@ -94,6 +94,11 @@ public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyExcep } out.put("error", exception.getMessage()); + JSONObject exceptionJson = new JSONObject(); + exceptionJson.put("message", exception.getMessage()); + exceptionJson.put("severity", exception.severity.toString()); + exceptionJson.put("cause", Util.getRootCause(exception).toString()); + out.put("exception", exceptionJson); linkPlayer.getSocket().send(out); } diff --git a/LavalinkServer/src/main/java/lavalink/server/player/Player.java b/LavalinkServer/src/main/java/lavalink/server/player/Player.java index ef689162e..8bbae55af 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/Player.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/Player.java @@ -22,8 +22,6 @@ package lavalink.server.player; -import com.sedmelluq.discord.lavaplayer.filter.equalizer.Equalizer; -import com.sedmelluq.discord.lavaplayer.filter.equalizer.EqualizerFactory; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; @@ -33,6 +31,7 @@ import io.netty.buffer.ByteBuf; import lavalink.server.io.SocketContext; import lavalink.server.io.SocketServer; +import lavalink.server.player.filters.FilterChain; import lavalink.server.config.ServerConfig; import moe.kyokobot.koe.VoiceConnection; import moe.kyokobot.koe.media.OpusAudioFrameProvider; @@ -54,9 +53,8 @@ public class Player extends AudioEventAdapter { private final AudioPlayer player; private final AudioLossCounter audioLossCounter = new AudioLossCounter(); private AudioFrame lastFrame = null; + private FilterChain filters; private ScheduledFuture myFuture = null; - private final EqualizerFactory equalizerFactory = new EqualizerFactory(); - private boolean isEqualizerApplied = false; public Player(SocketContext socketContext, String guildId, AudioPlayerManager audioPlayerManager, ServerConfig serverConfig) { this.socketContext = socketContext; @@ -97,33 +95,6 @@ public void setVolume(int volume) { player.setVolume(volume); } - public void setBandGain(int band, float gain) { - log.debug("Setting band {}'s gain to {}", band, gain); - equalizerFactory.setGain(band, gain); - - if (gain == 0.0f) { - if (!isEqualizerApplied) { - return; - } - - boolean shouldDisable = true; - - for (int i = 0; i < Equalizer.BAND_COUNT; i++) { - if (equalizerFactory.getGain(i) != 0.0f) { - shouldDisable = false; - } - } - - if (shouldDisable) { - this.player.setFilterFactory(null); - this.isEqualizerApplied = false; - } - } else if (!this.isEqualizerApplied) { - this.player.setFilterFactory(equalizerFactory); - this.isEqualizerApplied = true; - } - } - public JSONObject getState() { JSONObject json = new JSONObject(); @@ -203,4 +174,18 @@ public void retrieveOpusFrame(ByteBuf buf) { } } + @Nullable + public FilterChain getFilters() { + return filters; + } + + public void setFilters(FilterChain filters) { + this.filters = filters; + + if (filters.isEnabled()) { + player.setFilterFactory(filters); + } else { + player.setFilterFactory(null); + } + } } diff --git a/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt b/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt new file mode 100644 index 000000000..7609236fb --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt @@ -0,0 +1,58 @@ +package lavalink.server.player.filters + +import com.google.gson.Gson +import com.sedmelluq.discord.lavaplayer.filter.* +import com.sedmelluq.discord.lavaplayer.format.AudioDataFormat +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class FilterChain : PcmFilterFactory { + + companion object { + private val log: Logger = LoggerFactory.getLogger(FilterChain::class.java) + private val gson = Gson() + fun parse(json: String) = gson.fromJson(json, FilterChain::class.java)!! + } + + var volume: Float? = null + var equalizer: List? = null + private val karaoke: KaraokeConfig? = null + private val timescale: TimescaleConfig? = null + private val tremolo: TremoloConfig? = null + private val vibrato: VibratoConfig? = null + private val distortion: DistortionConfig? = null + private val rotation: RotationConfig? = null + private val channelMix: ChannelMixConfig? = null + private val lowPass: LowPassConfig? = null + + private fun buildList() = listOfNotNull( + volume?.let { VolumeConfig(it) }, + equalizer?.let { EqualizerConfig(it) }, + karaoke, + timescale, + tremolo, + vibrato, + distortion, + rotation, + channelMix, + lowPass + ) + + val isEnabled get() = buildList().any { it.isEnabled } + + override fun buildChain(track: AudioTrack?, format: AudioDataFormat, output: UniversalPcmAudioFilter): MutableList { + val enabledFilters = buildList().takeIf { it.isNotEmpty() } + ?: return mutableListOf() + + val pipeline = mutableListOf() + + for (filter in enabledFilters) { + val outputTo = pipeline.lastOrNull() ?: output + pipeline.add(filter.build(format, outputTo)) + } + + return pipeline.reversed().toMutableList() // Output last + } + +} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt b/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt new file mode 100644 index 000000000..3269039dd --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt @@ -0,0 +1,173 @@ +package lavalink.server.player.filters + +import com.github.natanbc.lavadsp.channelmix.ChannelMixPcmAudioFilter +import com.github.natanbc.lavadsp.karaoke.KaraokePcmAudioFilter +import com.github.natanbc.lavadsp.timescale.TimescalePcmAudioFilter +import com.github.natanbc.lavadsp.tremolo.TremoloPcmAudioFilter +import com.github.natanbc.lavadsp.vibrato.VibratoPcmAudioFilter +import com.github.natanbc.lavadsp.distortion.DistortionPcmAudioFilter +import com.github.natanbc.lavadsp.lowpass.LowPassPcmAudioFilter +import com.github.natanbc.lavadsp.rotation.RotationPcmAudioFilter +import com.github.natanbc.lavadsp.volume.VolumePcmAudioFilter +import com.sedmelluq.discord.lavaplayer.filter.AudioFilter +import com.sedmelluq.discord.lavaplayer.filter.FloatPcmAudioFilter +import com.sedmelluq.discord.lavaplayer.filter.UniversalPcmAudioFilter +import com.sedmelluq.discord.lavaplayer.filter.equalizer.Equalizer +import com.sedmelluq.discord.lavaplayer.format.AudioDataFormat + +class VolumeConfig(private var volume: Float) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return VolumePcmAudioFilter(output, format.channelCount).also { + it.volume = volume + } + } + + override val isEnabled: Boolean get() = volume != 1.0f +} + +class EqualizerConfig(bands: List) : FilterConfig() { + private val array = FloatArray(Equalizer.BAND_COUNT) { 0.0f } + + init { + bands.forEach { array[it.band] = it.gain } + } + + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter = + Equalizer(format.channelCount, output, array) + + override val isEnabled: Boolean get() = array.any { it != 0.0f } +} + +data class Band(val band: Int, val gain: Float) + +class KaraokeConfig( + private val level: Float = 1.0f, + private val monoLevel: Float = 1.0f, + private val filterBand: Float = 220.0f, + private val filterWidth: Float = 100.0f +) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return KaraokePcmAudioFilter(output, format.channelCount, format.sampleRate) + .setLevel(level) + .setMonoLevel(monoLevel) + .setFilterBand(filterBand) + .setFilterWidth(filterWidth) + } + override val isEnabled: Boolean get() = true +} + +class TimescaleConfig( + private val speed: Double = 1.0, + private val pitch: Double = 1.0, + private val rate: Double = 1.0 +) : FilterConfig() { + + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return TimescalePcmAudioFilter(output, format.channelCount, format.sampleRate) + .setSpeed(speed) + .setPitch(pitch) + .setRate(rate) + } + override val isEnabled: Boolean get() = speed != 1.0 || pitch != 1.0 || rate != 1.0 + +} + +class TremoloConfig( + private val frequency: Float = 2.0f, + private val depth: Float = 0.5f +) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return TremoloPcmAudioFilter(output, format.channelCount, format.sampleRate) + .setFrequency(frequency) + .setDepth(depth) + } + + override val isEnabled: Boolean get() = depth != 0.0f +} + +class VibratoConfig( + private val frequency: Float = 2.0f, + private val depth: Float = 0.5f +) : FilterConfig() { + + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return VibratoPcmAudioFilter(output, format.channelCount, format.sampleRate) + .setFrequency(frequency) + .setDepth(depth) + } + + override val isEnabled: Boolean get() = depth != 0.0f + +} + +class DistortionConfig( + private val sinOffset: Float = 0.0f, + private val sinScale: Float = 1.0f, + private val cosOffset: Float = 0.0f, + private val cosScale: Float = 1.0f, + private val tanOffset: Float = 0.0f, + private val tanScale: Float = 1.0f, + private val offset: Float = 0.0f, + private val scale: Float = 1.0f +) : FilterConfig() { + + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return DistortionPcmAudioFilter(output, format.channelCount) + .setSinOffset(sinOffset) + .setSinScale(sinScale) + .setCosOffset(cosOffset) + .setCosScale(cosScale) + .setTanOffset(tanOffset) + .setTanScale(tanScale) + .setOffset(offset) + .setScale(scale) + } + + override val isEnabled: Boolean get() = sinOffset != 0.0f || sinScale != 1.0f || cosOffset != 0.0f || cosScale != 1.0f || tanOffset != 0.0f || tanScale != 1.0f || offset != 0.0f || scale != 1.0f + +} + +class RotationConfig( + private val rotationHz: Double = 0.0 +) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return RotationPcmAudioFilter(output, format.sampleRate) + .setRotationSpeed(rotationHz) + } + + override val isEnabled: Boolean get() = rotationHz != 0.0 +} + +class ChannelMixConfig( + private val leftToLeft: Float = 1f, + private val leftToRight: Float = 0f, + private val rightToLeft: Float = 0f, + private val rightToRight: Float = 1f + +) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return ChannelMixPcmAudioFilter(output) + .setLeftToLeft(leftToLeft) + .setLeftToRight(leftToRight) + .setRightToLeft(rightToLeft) + .setRightToRight(rightToRight) + } + + override val isEnabled: Boolean get() = leftToLeft != 1f || leftToRight != 0f || rightToLeft != 0f || rightToRight != 1f +} + +class LowPassConfig( + private val smoothing: Float = 20.0f +) : FilterConfig() { + override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { + return LowPassPcmAudioFilter(output, format.sampleRate) + .setSmoothing(smoothing) + } + + override val isEnabled: Boolean get() = smoothing != 20.0f +} + +abstract class FilterConfig { + abstract fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter + abstract val isEnabled: Boolean +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/util/Util.java b/LavalinkServer/src/main/java/lavalink/server/util/Util.java index 87ad71d2f..c0b2b9b2c 100644 --- a/LavalinkServer/src/main/java/lavalink/server/util/Util.java +++ b/LavalinkServer/src/main/java/lavalink/server/util/Util.java @@ -50,4 +50,12 @@ public static String toMessage(AudioPlayerManager audioPlayerManager, AudioTrack return Base64.encodeBase64String(baos.toByteArray()); } + public static Throwable getRootCause(Throwable throwable) { + Throwable rootCause = throwable; + while (rootCause.getCause() != null) { + rootCause = rootCause.getCause(); + } + return rootCause; + } + } diff --git a/build.gradle b/build.gradle index e292dc611..6db7bd7ba 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ allprojects { jcenter() // JDA and some other stuff mavenLocal() // useful for developing maven { url "https://jitpack.io" } // build projects directly from github - maven { url 'https://dl.bintray.com/sedmelluq/com.sedmelluq' } + maven { url 'https://m2.dv8tion.net/releases' } } group = 'lavalink' @@ -53,21 +53,24 @@ subprojects { ext { //@formatter:off - lavaplayerVersion = '1.3.76' + + lavaplayerVersion = '1.3.78' lavaplayerIpRotatorVersion = '0.2.3' jdaNasVersion = '1.1.0' jappVersion = '1.3.2-MINN' koeVersion = '1.0.1' + lavaDspVersion = '0.7.7' springBootVersion = "${springBootVersion}" springWebSocketVersion = '5.1.9.RELEASE' logbackVersion = '1.2.3' sentryLogbackVersion = '1.7.7' - oshiVersion = '3.13.3' + oshiVersion = '5.7.4' jsonOrgVersion = '20180813' spotbugsAnnotationsVersion = '3.1.6' prometheusVersion = '0.5.0' commonsLangVersion = '3.8' + gsonVersion = '2.8.5' //@formatter:on }