fix(ambiance): stabiliser la lecture du disque blanc #87

Merged
koka merged 1 commits from fix/white-disc-playback-crash into main 2026-08-29 22:46:45 +00:00
7 changed files with 190 additions and 37 deletions
+7 -5
View File
@@ -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 dorigine. Laccè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 nest
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 dorigine. Laccè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 nest acceptée par le serveur.
+32 -6
View File
@@ -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")
}
[
@@ -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;
@@ -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;
}
@@ -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<AudioTrack> load(DefaultAudioPlayerManager manager) {
CompletableFuture<AudioTrack> 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;
}
}
+8 -8
View File
@@ -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"
+1 -1
View File
@@ -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