Ship the Android sound packs as Opus

The build encodes TeamSpeak's packs to Ogg Opus at 64 kbps before bundling
them, which takes the sounds from 21.3 MB to 2.0 MB, in the APK and again
once unpacked on the phone. The encoding runs the desktop code's libopus
on the build machine, through a build-only :desktop module compiled from
the same sources as the Maven one.

Both clients play Opus sounds: the sound player (now PackSoundPlayer, as it
no longer plays only wave files) decodes Ogg Opus with the platform's
libopus, and a pack's play("x.wav") finds x.opus, so converted packs keep
their scripts. SoundPackConverter turns any pack folder into Opus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:12:50 +00:00
parent 02ff63341e
commit 4b50f37735
12 changed files with 456 additions and 32 deletions

View File

@@ -0,0 +1,65 @@
package com.ts3client.sound;
import com.ts3client.audio.desktop.NativeOpusCodec;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** A pack converted to Opus plays back as the sound it was, found under its old name. */
class OggOpusFileTest {
@TempDir
File dir;
@Test
void convertedPackPlaysTheSameSound() throws IOException {
File source = new File(dir, "wav/pack");
Files.createDirectories(source.toPath());
Files.writeString(new File(source, "settings.ini").toPath(),
"[info]\nname = Test\n[soundfiles]\nCONNECTION_CONNECTED = play(\"tone.wav\")\n");
int rate = 44_100;
float[] tone = new float[rate / 2 + 123];
for (int i = 0; i < tone.length; i++) tone[i] = (float) (0.5 * Math.sin(2 * Math.PI * 440 * i / rate));
writeWave(new File(source, "tone.wav"), tone, rate);
File target = new File(dir, "opus");
NativeOpusCodec opus = new NativeOpusCodec();
SoundPackConverter.convert(new File(dir, "wav"), target, opus);
SoundPack pack = SoundPack.load(new File(target, "pack"));
assertNotNull(pack);
File file = pack.file("tone.wav");
assertEquals("tone.opus", file.getName());
float[] expected = WaveFile.readMono(new File(source, "tone.wav")).at48k();
float[] decoded = OggOpusFile.readMono(file, opus);
assertEquals(expected.length, decoded.length);
double dot = 0, a = 0, b = 0;
for (int i = 0; i < decoded.length; i++) {
dot += expected[i] * decoded[i];
a += expected[i] * expected[i];
b += decoded[i] * decoded[i];
}
double correlation = dot / Math.sqrt(a * b);
assertTrue(correlation > 0.99, "correlation " + correlation);
}
private static void writeWave(File file, float[] samples, int rate) throws IOException {
ByteBuffer b = ByteBuffer.allocate(44 + samples.length * 2).order(ByteOrder.LITTLE_ENDIAN);
b.put("RIFF".getBytes()).putInt(36 + samples.length * 2).put("WAVE".getBytes());
b.put("fmt ".getBytes()).putInt(16).putShort((short) 1).putShort((short) 1)
.putInt(rate).putInt(rate * 2).putShort((short) 2).putShort((short) 16);
b.put("data".getBytes()).putInt(samples.length * 2);
for (float s : samples) b.putShort((short) Math.round(s * 32767));
Files.write(file.toPath(), b.array());
}
}