diff --git a/ambiance/WHITE_DISC.md b/ambiance/WHITE_DISC.md index 95c4223..16cecf4 100644 --- a/ambiance/WHITE_DISC.md +++ b/ambiance/WHITE_DISC.md @@ -24,8 +24,10 @@ Ambiance conserve ses données v2 ; son protocole passe de 5 à 6. ## Dépendances clientes embarquées -Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et youtube-source `1.18.2` (MIT), ainsi que -leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans -les archives d’origine. L’accès YouTube est effectué uniquement par le client et peut cesser de -fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire n’est -acceptée par le serveur. +Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et sur le commit youtube-source +`f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3` (MIT), épinglé pour intégrer les correctifs de lecture +YouTube publiés après `1.18.2`. Le profil iOS corrigé est utilisé sans compte, OAuth, jeton privé ou +service de chiffrement tiers. Les dépendances sont placées en JAR imbriqués par Loom et leurs avis +de licence restent dans les archives d’origine. L’accès YouTube est effectué uniquement par le +client et peut cesser de fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou +URL arbitraire n’est acceptée par le serveur. diff --git a/ambiance/build.gradle b/ambiance/build.gradle index d5ca96a..8c659be 100644 --- a/ambiance/build.gradle +++ b/ambiance/build.gradle @@ -1,5 +1,7 @@ import groovy.json.JsonSlurper +def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT" + configurations { whiteDiscLibraries implementation.extendsFrom whiteDiscLibraries @@ -13,14 +15,14 @@ dependencies { exclude group: "org.slf4j", module: "slf4j-api" exclude group: "org.jetbrains", module: "annotations" } - whiteDiscLibraries("dev.lavalink.youtube:v2:1.18.2") { + whiteDiscLibraries("dev.lavalink.youtube:v2:${youtubeSourceVersion}") { exclude group: "org.slf4j", module: "slf4j-api" exclude group: "org.jetbrains", module: "annotations" } [ "dev.arbjerg:lavaplayer-natives:2.2.7", "dev.arbjerg:lava-common:2.2.7", - "dev.lavalink.youtube:common:1.18.2", + "dev.lavalink.youtube:common:${youtubeSourceVersion}", "org.mozilla:rhino-engine:1.7.15", "org.mozilla:rhino:1.7.15", "commons-io:commons-io:2.13.0", @@ -64,15 +66,24 @@ tasks.register("whiteDiscUrlSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke" } +tasks.register("whiteDiscPlaybackSmoke", JavaExec) { + group = "verification" + description = "Manually checks live YouTube loading and PCM decoding for the white disc." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscPlaybackSmoke" +} + tasks.named("test") { failOnNoDiscoveredTests = false } tasks.register("verifyAmbiance") { group = "verification" - description = "Checks Ambiance alpha.17, including deferred white-disc playback startup." + description = "Checks Ambiance alpha.18, including resilient white-disc YouTube playback." inputs.files(fileTree("src/main/java")) inputs.files(fileTree("src/main/resources")) + inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java")) inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json")) inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.127-daily-ambiance-sodium-iris-to-alpha.128.json")) inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.128-ambiance-visuals-canon-iris-to-alpha.129.json")) @@ -122,8 +133,12 @@ tasks.register("verifyAmbiance") { file("src/main/resources/data/ambiance/jukebox_song/white_disc.json")) def levelEventMixin = file( "src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelEventHandlerMixin.java").text + def whiteDiscAudioPlayer = file( + "src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java").text + def whiteDiscPlaybackSmoke = file( + "src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text - if (project.version.toString() != "0.0.0-alpha.17" + if (project.version.toString() != "0.0.0-alpha.18" || migration.source?.pack_version != "26.2.0-alpha.125" || migration.target?.pack_version != "26.2.0-alpha.126" || migration.target?.modules?.ambiance != "0.0.0-alpha.1" @@ -327,6 +342,15 @@ tasks.register("verifyAmbiance") { || !javaText.contains('client.level.dimension().equals(playback.dimension())') || !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L') || !javaText.contains('new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE') + || !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())') + || !javaText.contains('player.addListener(new AudioEventAdapter()') + || !javaText.contains('failPlayback(client, key, failedPlayer)') + || !javaText.contains('initialWaitAttempted') + || !javaText.contains('frame = player.provide()') + || !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())') + || whiteDiscAudioPlayer.contains('.release()') + || !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM') + || !whiteDiscPlaybackSmoke.contains('Decoded audible PCM data') || !javaText.contains('SoundSource.RECORDS') || !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)') || !javaText.contains('LONG_HOSTS = Set.of(') @@ -348,8 +372,10 @@ tasks.register("verifyAmbiance") { } } || !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7') - || !file("build.gradle").text.contains('dev.lavalink.youtube:v2:1.18.2')) { - throw new GradleException("Ambiance alpha.17 white-disc playback contract is incomplete") + || !file("build.gradle").text.contains( + 'def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"') + || !file("build.gradle").text.contains('dev.lavalink.youtube:v2:${youtubeSourceVersion}')) { + throw new GradleException("Ambiance alpha.18 white-disc playback contract is incomplete") } [ diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java index 0e959f8..e314bd7 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java @@ -12,13 +12,12 @@ import net.minecraft.client.sounds.AudioStream; /** Adapts bounded mono PCM frames from Lavaplayer to Minecraft's streaming OpenAL channel. */ final class LavaplayerAudioStream implements AudioStream { static final int SAMPLE_RATE = 48_000; - private static final int FRAME_SAMPLES = 960; - private static final int BYTES_PER_SAMPLE = 2; private static final AudioFormat FORMAT = new AudioFormat(SAMPLE_RATE, 16, 1, true, false); private final AudioPlayer player; private byte[] pending = new byte[0]; private int pendingOffset; + private boolean initialWaitAttempted; private volatile boolean closed; private volatile boolean ended; @@ -45,9 +44,8 @@ final class LavaplayerAudioStream implements AudioStream { AudioFrame frame = nextFrame(); if (frame == null) { if (ended) break; - int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE); - for (int index = 0; index < silence; index++) output.put((byte) 0); - continue; + while (output.hasRemaining()) output.put((byte) 0); + break; } if (frame.isTerminator()) { ended = true; @@ -63,7 +61,13 @@ final class LavaplayerAudioStream implements AudioStream { private AudioFrame nextFrame() throws IOException { try { - AudioFrame frame = player.provide(2, TimeUnit.SECONDS); + AudioFrame frame; + if (!initialWaitAttempted) { + initialWaitAttempted = true; + frame = player.provide(2, TimeUnit.SECONDS); + } else { + frame = player.provide(); + } if (frame == null && player.getPlayingTrack() == null) ended = true; return frame; } catch (TimeoutException ignored) { @@ -78,6 +82,10 @@ final class LavaplayerAudioStream implements AudioStream { return ended; } + boolean uses(AudioPlayer expectedPlayer) { + return player == expectedPlayer; + } + @Override public void close() { if (closed) return; diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java index 18feb44..632a368 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java @@ -5,10 +5,12 @@ import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat; import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import dev.lavalink.youtube.YoutubeAudioSourceManager; +import dev.lavalink.youtube.clients.Ios; import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor; import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor; @@ -114,24 +116,49 @@ public final class WhiteDiscAudioPlayer { ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager()) .ambiance$getSoundEngine()).ambiance$getChannelAccess(); channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> { - if (handle == null || REQUESTS.getOrDefault(key, -1L) != request) { - if (handle != null) handle.release(); + if (handle == null || REQUESTS.getOrDefault(key, -1L) != request + || !validTarget(client, pos)) { + if (handle != null) handle.execute(channel -> channel.stop()); + if (REQUESTS.getOrDefault(key, -1L) == request) REQUESTS.remove(key); return; } AudioPlayer player = MANAGER.createPlayer(); - player.playTrack(track); LavaplayerAudioStream stream = new LavaplayerAudioStream(player); Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream); PLAYBACKS.put(key, playback); REQUESTS.remove(key); + player.addListener(new AudioEventAdapter() { + @Override + public void onTrackException(AudioPlayer failedPlayer, AudioTrack failedTrack, + FriendlyException exception) { + AmbianceMod.LOGGER.warn("White-disc playback failed for YouTube video {}: {}", + failedTrack.getIdentifier(), exception.getMessage()); + client.execute(() -> failPlayback(client, key, failedPlayer)); + } + + @Override + public void onTrackStuck(AudioPlayer stuckPlayer, AudioTrack stuckTrack, + long thresholdMs) { + AmbianceMod.LOGGER.warn("White-disc playback stalled for YouTube video {} after {} ms", + stuckTrack.getIdentifier(), thresholdMs); + client.execute(() -> failPlayback(client, key, stuckPlayer)); + } + }); + player.playTrack(track); float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); handle.execute(channel -> { - channel.setSelfPosition(Vec3.atCenterOf(pos)); - channel.setRelative(false); - channel.linearAttenuation(ATTENUATION_DISTANCE); - channel.setVolume(volume); - channel.attachBufferStream(stream); - channel.play(); + try { + channel.setSelfPosition(Vec3.atCenterOf(pos)); + channel.setRelative(false); + channel.linearAttenuation(ATTENUATION_DISTANCE); + channel.setVolume(volume); + channel.attachBufferStream(stream); + channel.play(); + } catch (RuntimeException | LinkageError exception) { + AmbianceMod.LOGGER.error("Unable to attach white-disc audio to Minecraft's sound engine", + exception); + client.execute(() -> failPlayback(client, key, player)); + } }); if (client.player != null) client.player.sendOverlayMessage( Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title)); @@ -145,6 +172,14 @@ public final class WhiteDiscAudioPlayer { Component.translatable("message.ambiance.white_disc.failed")); } + private static void failPlayback(Minecraft client, long key, AudioPlayer player) { + Playback playback = PLAYBACKS.get(key); + if (playback == null || !playback.stream().uses(player)) return; + stop(key); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.failed")); + } + public static void stop(BlockPos pos) { stop(pos.asLong()); } @@ -156,7 +191,6 @@ public final class WhiteDiscAudioPlayer { if (playback == null) return; playback.stream().close(); playback.handle().execute(channel -> channel.stop()); - playback.handle().release(); } public static void tick(Minecraft client) { @@ -200,7 +234,7 @@ public final class WhiteDiscAudioPlayer { new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false)); manager.setFrameBufferDuration(5_000); manager.setItemLoaderThreadPoolSize(2); - manager.registerSourceManager(new YoutubeAudioSourceManager(false)); + manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios())); return manager; } diff --git a/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java b/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java new file mode 100644 index 0000000..3650393 --- /dev/null +++ b/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java @@ -0,0 +1,83 @@ +package fr.koka99cab.sanctuary26.ambiance.client.audio; + +import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.lavalink.youtube.YoutubeAudioSourceManager; +import dev.lavalink.youtube.clients.Ios; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** Manual network smoke test for the exact YouTube playback path used by white discs. */ +public final class WhiteDiscPlaybackSmoke { + private static final String VIDEO_URL = "https://www.youtube.com/watch?v=Ue5ZBe-GzSM"; + + private WhiteDiscPlaybackSmoke() { + } + + public static void main(String[] args) throws Exception { + DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager(); + manager.getConfiguration().setOutputFormat( + new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false)); + manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios())); + try { + AudioTrack track = load(manager).get(30, TimeUnit.SECONDS); + AudioPlayer player = manager.createPlayer(); + player.playTrack(track); + LavaplayerAudioStream stream = new LavaplayerAudioStream(player); + int decodedBytes = 0; + for (int index = 0; index < 4; index++) { + ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2); + if (pcm == null) break; + while (pcm.hasRemaining()) { + if (pcm.get() != 0) decodedBytes++; + } + } + if (decodedBytes == 0) { + throw new IllegalStateException("YouTube playback produced no audible PCM data"); + } + System.out.println("Decoded audible PCM data from " + + track.getInfo().title); + stream.close(); + } finally { + manager.shutdown(); + } + } + + private static CompletableFuture load(DefaultAudioPlayerManager manager) { + CompletableFuture result = new CompletableFuture<>(); + manager.loadItem(VIDEO_URL, new AudioLoadResultHandler() { + @Override + public void trackLoaded(AudioTrack track) { + result.complete(track); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) { + AudioTrack selected = playlist.getSelectedTrack(); + if (selected == null && !playlist.getTracks().isEmpty()) { + selected = playlist.getTracks().getFirst(); + } + if (selected == null) result.completeExceptionally( + new IllegalStateException("YouTube playlist contained no track")); + else result.complete(selected); + } + + @Override + public void noMatches() { + result.completeExceptionally(new IllegalStateException("YouTube returned no match")); + } + + @Override + public void loadFailed(FriendlyException exception) { + result.completeExceptionally(exception); + } + }); + return result; + } +} diff --git a/build.gradle b/build.gradle index 68d1220..e956b0a 100644 --- a/build.gradle +++ b/build.gradle @@ -36,8 +36,8 @@ allprojects { exclusiveContent { forRepository { maven { - name = "Lavalink" - url = "https://maven.lavalink.dev/releases" + name = "LavalinkSnapshots" + url = "https://maven.lavalink.dev/snapshots" } } filter { includeGroup "dev.lavalink.youtube" } @@ -4548,7 +4548,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") { def ouchManifest = new JsonSlurper().parse(file("ouch/src/main/resources/fabric.mod.json")) def rootBuild = file("build.gradle").text if (rootProject.pack_version != "26.2.0-alpha.214" - || rootProject.ambiance_version != "0.0.0-alpha.17" + || rootProject.ambiance_version != "0.0.0-alpha.18" || rootProject.ouch_version != "0.0.0-alpha.10" || migration.source?.pack_version != "26.2.0-alpha.199" || migration.source?.modules != [ambiance: "0.0.0-alpha.11", ouch: "0.0.0-alpha.8"] @@ -5319,7 +5319,7 @@ tasks.register("verifyCanapliaFoundation") { "ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/api/visual/AmbianceVisualEffectApi.java").text if (rootProject.pack_version != "26.2.0-alpha.214" || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.ambiance_version != "0.0.0-alpha.17" + || rootProject.ambiance_version != "0.0.0-alpha.18" || migration.issue != 35 || migration.source?.fabric_id != "canaplia" || migration.source?.version != "2.0.0" @@ -5570,7 +5570,7 @@ tasks.register("verifyCanapliaAlpha209Release") { itsalive: "0.0.0-alpha.33", onlyfun: "0.0.0-alpha.20"] if (rootProject.pack_version != "26.2.0-alpha.214" - || rootProject.ambiance_version != "0.0.0-alpha.17" + || rootProject.ambiance_version != "0.0.0-alpha.18" || rootProject.itsalive_version != "0.0.0-alpha.36" || rootProject.onlyfun_version != "0.0.0-alpha.24" || migration.source?.pack_version != "26.2.0-alpha.207" @@ -5785,7 +5785,7 @@ tasks.register("verifyAlpha211Release") { def currentModules = expectedTarget + [ sanctuary: "0.0.0-alpha.120", ambiance: "0.0.0-alpha.17"] - def workspaceModules = currentModules + [ambiance: "0.0.0-alpha.17"] + def workspaceModules = currentModules + [ambiance: "0.0.0-alpha.18"] def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11] def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22] def manifests = expectedTarget.collectEntries { id, version -> @@ -5948,7 +5948,7 @@ tasks.register("verifyAlpha213Release") { it.path == "mods/jei-26.2-fabric-30.28.0.193.jar" } if (rootProject.pack_version != "26.2.0-alpha.214" - || rootProject.ambiance_version != "0.0.0-alpha.17" + || rootProject.ambiance_version != "0.0.0-alpha.18" || migration.source?.pack_version != "26.2.0-alpha.212" || migration.source?.modules != [ambiance: "0.0.0-alpha.15"] || migration.target?.pack_version != "26.2.0-alpha.213" @@ -6016,7 +6016,7 @@ tasks.register("verifyAlpha214Release") { def urlSmoke = file( "ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrlSmoke.java").text if (rootProject.pack_version != "26.2.0-alpha.214" - || rootProject.ambiance_version != "0.0.0-alpha.17" + || rootProject.ambiance_version != "0.0.0-alpha.18" || migration.source?.pack_version != "26.2.0-alpha.213" || migration.source?.modules != [ambiance: "0.0.0-alpha.16"] || migration.target?.pack_version != "26.2.0-alpha.214" diff --git a/gradle.properties b/gradle.properties index b78f4b0..3343c1d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ fabric_api_version=0.158.0+26.2 # Standby modules stay at zero until their migration really begins. mod_version=0.0.0-alpha.0 module_lifecycle=standby -ambiance_version=0.0.0-alpha.17 +ambiance_version=0.0.0-alpha.18 ambiance_lifecycle=active redstoner_version=0.0.0-alpha.10 redstoner_lifecycle=active