fix(ambiance): preserve white disc native audio rate

This commit is contained in:
2026-08-30 06:51:22 +02:00
parent 8787969347
commit 6fc93ca732
7 changed files with 71 additions and 23 deletions
+6 -2
View File
@@ -16,8 +16,12 @@ jukebox vanilla.
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
trente minutes, et limite les lectures simultanées à quatre ;
- la lecture utilise le canal `RECORDS`, un flux PCM mono positionnel et une atténuation à
64 blocs. Elle sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se ferme.
- la source AAC stéréo est décodée à 48 kHz puis convertie explicitement en PCM mono 16 bits
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
vitesse et la hauteur originales tout en restant positionnelle ;
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
64 blocs. La lecture sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se
ferme.
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
+8 -5
View File
@@ -102,7 +102,7 @@ tasks.named("test") {
tasks.register("verifyAmbiance") {
group = "verification"
description = "Checks Ambiance alpha.21, including corrected white-disc pitch and Alpha textures."
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
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"))
@@ -161,7 +161,7 @@ tasks.register("verifyAmbiance") {
def whiteDiscPlaybackSmoke = file(
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
if (project.version.toString() != "0.0.0-alpha.21"
if (project.version.toString() != "0.0.0-alpha.22"
|| 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"
@@ -364,15 +364,18 @@ tasks.register("verifyAmbiance") {
|| !javaText.contains('tickPendingStarts(client)')
|| !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 Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|| !javaText.contains('initialWaitAttempted')
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 4.0F / 3.0F')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|| !javaText.contains('SOURCE_CHANNELS = 2')
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|| whiteDiscAudioPlayer.contains('.release()')
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
@@ -472,7 +475,7 @@ tasks.register("verifyAlphaTextures") {
include "**/*golden*days*.zip", "**/*golden*days*.jar"
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
}.files
if (project.version.toString() != "0.0.0-alpha.21"
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|| migration.alpha_textures?.client_only != true
|| migration.alpha_textures?.bundled_assets != false
@@ -12,6 +12,10 @@ 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;
static final int SOURCE_CHANNELS = 2;
static final int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = Short.BYTES;
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
private static final AudioFormat FORMAT =
@@ -65,7 +69,7 @@ final class LavaplayerAudioStream implements AudioStream {
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
lastFrameTimecode = frame.getTimecode();
decodedFrames++;
pending = frame.getData();
pending = downmixStereoFrame(frame.getData());
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
@@ -73,6 +77,27 @@ final class LavaplayerAudioStream implements AudioStream {
return output;
}
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
}
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
for (int sourceOffset = 0, targetOffset = 0;
sourceOffset < stereo.length;
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
short left = littleEndianSample(stereo, sourceOffset);
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
mono[targetOffset] = (byte) mixed;
mono[targetOffset + 1] = (byte) (mixed >>> 8);
}
return mono;
}
private static short littleEndianSample(byte[] pcm, int offset) {
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
}
private AudioFrame nextFrame() throws IOException {
try {
long waitMillis = initialWaitAttempted
@@ -36,8 +36,8 @@ public final class WhiteDiscAudioPlayer {
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
private static final float ATTENUATION_DISTANCE = 64.0F;
/** Compensates Lavaplayer's low AAC cadence before PCM reaches OpenAL. */
static final float PLAYBACK_PITCH = 4.0F / 3.0F;
/** OpenAL must preserve the decoded music's native speed and pitch. */
static final float PLAYBACK_PITCH = 1.0F;
private static final DefaultAudioPlayerManager MANAGER = createManager();
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
private static final Map<Long, Long> REQUESTS = new HashMap<>();
@@ -237,7 +237,8 @@ public final class WhiteDiscAudioPlayer {
private static DefaultAudioPlayerManager createManager() {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.setFrameBufferDuration(5_000);
manager.setItemLoaderThreadPoolSize(2);
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
@@ -7,7 +7,6 @@ import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
import java.lang.reflect.Proxy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -15,22 +14,27 @@ import java.util.concurrent.atomic.AtomicInteger;
/** Deterministic check for Minecraft's four one-second streaming buffers. */
public final class LavaplayerAudioStreamSmoke {
private static final int FRAME_SAMPLES = 960;
private static final int FRAME_BYTES = FRAME_SAMPLES * 2;
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
private LavaplayerAudioStreamSmoke() {
}
public static void main(String[] args) throws Exception {
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 4.0F / 3.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc AAC pitch compensation changed");
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc playback pitch is not native");
}
Queue<AudioFrame> frames = new ArrayDeque<>();
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
1, LavaplayerAudioStream.SAMPLE_RATE, FRAME_SAMPLES, false);
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
FRAME_SAMPLES, false);
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
byte[] data = new byte[FRAME_BYTES];
Arrays.fill(data, (byte) (index + 1));
byte[] data = new byte[SOURCE_FRAME_BYTES];
for (int offset = 0; offset < data.length; offset += 4) {
putLittleEndian(data, offset, (short) (1_000 + index));
putLittleEndian(data, offset + 2, (short) (3_000 + index));
}
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
}
@@ -63,9 +67,14 @@ public final class LavaplayerAudioStreamSmoke {
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
}
while (pcm.hasRemaining()) {
if (pcm.get() == 0) {
throw new IllegalStateException("The stream padded available PCM with silence");
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
short expected = (short) (2_000 + frame);
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
int low = Byte.toUnsignedInt(pcm.get());
short actual = (short) (low | pcm.get() << 8);
if (actual != expected) {
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
}
}
}
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
@@ -80,4 +89,9 @@ public final class LavaplayerAudioStreamSmoke {
throw new IllegalStateException("Closing the stream did not destroy its player");
}
}
private static void putLittleEndian(byte[] data, int offset, short sample) {
data[offset] = (byte) sample;
data[offset + 1] = (byte) (sample >>> 8);
}
}
@@ -23,7 +23,8 @@ public final class WhiteDiscPlaybackSmoke {
public static void main(String[] args) throws Exception {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
try {
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
+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.21
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.11
redstoner_lifecycle=active