From 4b50f3773543e439ea679eb039045a8e9119dd5e Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 26 Sep 2026 17:12:50 +0000 Subject: [PATCH] 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 --- android/app/build.gradle.kts | 33 ++- android/desktop/build.gradle.kts | 20 ++ android/settings.gradle.kts | 4 +- ts3-client/README.md | 2 +- .../com/ts3client/audio/AudioBackend.java | 4 +- .../java/com/ts3client/sound/OggOpusFile.java | 218 ++++++++++++++++++ ...vSoundPlayer.java => PackSoundPlayer.java} | 32 +-- .../java/com/ts3client/sound/SoundPack.java | 11 +- .../ts3client/sound/SoundPackConverter.java | 54 +++++ .../java/com/ts3client/sound/WaveFile.java | 19 ++ .../audio/desktop/EncodeSoundPacks.java | 26 +++ .../com/ts3client/sound/OggOpusFileTest.java | 65 ++++++ 12 files changed, 456 insertions(+), 32 deletions(-) create mode 100644 android/desktop/build.gradle.kts create mode 100644 ts3-client/core/src/main/java/com/ts3client/sound/OggOpusFile.java rename ts3-client/core/src/main/java/com/ts3client/sound/{WavSoundPlayer.java => PackSoundPlayer.java} (87%) create mode 100644 ts3-client/core/src/main/java/com/ts3client/sound/SoundPackConverter.java create mode 100644 ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/EncodeSoundPacks.java create mode 100644 ts3-client/desktop/src/test/java/com/ts3client/sound/OggOpusFileTest.java diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ba6d99d..2416741 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,20 +4,28 @@ plugins { } /** - * Copies the sound packs of an installed TeamSpeak 3 client into the app's assets, as the - * desktop client borrows them from the installation. They are TeamSpeak's and not in the - * repository; a build without the client ships no packs. + * Puts the sound packs of an installed TeamSpeak 3 client into the app's assets, as the + * desktop client borrows them from the installation, with the sounds encoded as Opus + * (a fifteenth of the WAVs). They are TeamSpeak's and not in the repository; a build + * without the client ships no packs. */ abstract class BundleSoundPacks : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val packs: DirectoryProperty + /** The desktop code whose libopus does the encoding. */ + @get:Classpath + abstract val encoder: ConfigurableFileCollection + @get:OutputDirectory abstract val assets: DirectoryProperty + @get:Inject + abstract val exec: ExecOperations + @TaskAction - fun copy() { + fun encode() { val out = assets.get().asFile out.deleteRecursively() val source = packs.get().asFile @@ -25,7 +33,20 @@ abstract class BundleSoundPacks : DefaultTask() { logger.warn("No TeamSpeak 3 sound packs at $source; the app ships without sounds") return } - source.copyRecursively(File(out, "sound")) + exec.javaexec { + classpath = encoder + mainClass.set("com.ts3client.audio.desktop.EncodeSoundPacks") + jvmArgs("--enable-native-access=ALL-UNNAMED") + args(source.path, File(out, "sound").path) + } + } +} + +val soundPackEncoder: Configuration by configurations.creating { + isCanBeConsumed = false + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR)) } } @@ -35,6 +56,7 @@ val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir") val bundleSoundPacks = tasks.register("bundleSoundPacks") { packs.set(File(ts3ClientDir, "sound")) + encoder.from(soundPackEncoder) assets.set(layout.buildDirectory.dir("generated/soundPacks")) } @@ -87,6 +109,7 @@ android { dependencies { implementation(project(":audio")) + soundPackEncoder(project(":desktop")) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) diff --git a/android/desktop/build.gradle.kts b/android/desktop/build.gradle.kts new file mode 100644 index 0000000..5d94476 --- /dev/null +++ b/android/desktop/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + `java-library` +} + +// The desktop client's platform code, run on the build machine only: its libopus +// encodes the sound packs the app ships (see :app's bundleSoundPacks). +java { + sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/java"))) + sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/resources"))) +} + +tasks.withType().configureEach { + // The FFM API it reaches libopus through; the build has to run on JDK 25 or newer. + options.release = 25 + options.encoding = "UTF-8" +} + +dependencies { + implementation(project(":core")) +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 5a91cdc..e2e799c 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,5 +16,5 @@ dependencyResolutionManagement { rootProject.name = "ts3-android" -// core and ts3j compile the very sources the desktop's Maven build does. -include(":ts3j", ":core", ":audio", ":app") +// core, ts3j and desktop compile the very sources the desktop's Maven build does. +include(":ts3j", ":core", ":desktop", ":audio", ":app") diff --git a/ts3-client/README.md b/ts3-client/README.md index a5515c0..00bc6a4 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -261,7 +261,7 @@ core/ com.ts3client │ ├── NotificationSettings per-action enabled / important flags │ ├── SoundNotifier decides what is heard (pack + config + mute state) │ ├── SoundPlayer renders a sound -│ └── WavSoundPlayer decodes, resamples and mixes sounds onto one line +│ └── PackSoundPlayer decodes, resamples and mixes sounds onto one line └── teamspeak the official client's own data files ├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store ├── SqliteReader minimal read-only SQLite 3 file reader (no driver) diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java index ca10c8d..135d73d 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java @@ -3,7 +3,7 @@ package com.ts3client.audio; import com.ts3client.audio.opus.OpusCodec; import com.ts3client.config.Settings; import com.ts3client.sound.SoundPlayer; -import com.ts3client.sound.WavSoundPlayer; +import com.ts3client.sound.PackSoundPlayer; /** * A platform's audio stack: its devices and its libopus. Injected into the connection @@ -29,7 +29,7 @@ public interface AudioBackend { /** Player for notification sounds (sound packs); shared by all connections. */ default SoundPlayer createSoundPlayer(Settings settings) { - return new WavSoundPlayer(io(), settings.outputDevice); + return new PackSoundPlayer(io(), opus(), settings.outputDevice); } /** Human-readable codec/backend description, e.g. for an "about" line. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/OggOpusFile.java b/ts3-client/core/src/main/java/com/ts3client/sound/OggOpusFile.java new file mode 100644 index 0000000..68456b8 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/sound/OggOpusFile.java @@ -0,0 +1,218 @@ +package com.ts3client.sound; + +import com.ts3client.audio.VoiceFormat; +import com.ts3client.audio.opus.OpusCodec; +import com.ts3client.audio.opus.OpusDecoder; +import com.ts3client.audio.opus.OpusEncoder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Ogg Opus files (RFC 7845), the compact form of a sound pack's sounds: decoded to mono + * 48 kHz for playback, and written from it by {@link SoundPackConverter}. + */ +public final class OggOpusFile { + + public static final String EXTENSION = ".opus"; + + /** Opus always decodes at 48 kHz; 120 ms is the longest packet. */ + private static final int MAX_PACKET_SAMPLES = 5760; + private static final int FRAME_SAMPLES = VoiceFormat.FRAME_SIZE; + /** + * libopus' encoder delay at 48 kHz for 20 ms frames, which the file tells the + * decoder to drop (the SPI does not expose {@code OPUS_GET_LOOKAHEAD}). + */ + private static final int PRE_SKIP = 312; + /** Packets per page: a page holds up to 255 segments, and short sounds are a few pages anyway. */ + private static final int PACKETS_PER_PAGE = 50; + + private static final int FLAG_FIRST = 0x02; + private static final int FLAG_LAST = 0x04; + + private OggOpusFile() { + } + + /** Decodes a file to mono samples in [-1, 1] at 48 kHz. */ + static float[] readMono(File file, OpusCodec opus) throws IOException { + List pages = pages(Files.readAllBytes(file.toPath())); + List packets = packets(pages); + if (packets.size() < 2) throw new IOException("no Opus stream"); + ByteBuffer head = ByteBuffer.wrap(packets.get(0)).order(ByteOrder.LITTLE_ENDIAN); + if (head.remaining() < 19 || !new String(packets.get(0), 0, 8, StandardCharsets.US_ASCII).equals("OpusHead")) { + throw new IOException("not an Opus stream"); + } + int channels = head.get(9) & 0xff; + int preSkip = head.getShort(10) & 0xffff; + if (channels < 1 || channels > 2) throw new IOException("unsupported channel count " + channels); + + float[] mono = new float[0]; + int length = 0; + float[] frame = new float[MAX_PACKET_SAMPLES * channels]; + try (OpusDecoder decoder = opus.createDecoder(VoiceFormat.SAMPLE_RATE, MAX_PACKET_SAMPLES, channels)) { + for (byte[] packet : packets.subList(2, packets.size())) { + int n = decoder.decode(packet, frame); + if (length + n > mono.length) mono = Arrays.copyOf(mono, Math.max(mono.length * 2, length + n)); + for (int i = 0; i < n; i++) { + float sum = 0; + for (int c = 0; c < channels; c++) sum += frame[i * channels + c]; + mono[length + i] = sum / channels; + } + length += n; + } + } + // The last page's granule position marks where the sound ends, past the padding. + long end = pages.get(pages.size() - 1).granule; + int stop = (int) Math.min(length, end >= preSkip ? end : length); + return stop <= preSkip ? new float[0] : Arrays.copyOfRange(mono, preSkip, stop); + } + + /** + * Encodes mono 48 kHz samples into a file. + * + * @param inputRate the rate the sound was recorded at, kept in the header for reference + */ + static void write(File file, float[] mono, int inputRate, OpusEncoder encoder) throws IOException { + List audio = new ArrayList<>(); + float[] frame = new float[FRAME_SAMPLES]; + // Encoded past the end by the encoder's delay, so the tail comes out whole. + int total = PRE_SKIP + mono.length; + for (int start = 0; start < total; start += FRAME_SAMPLES) { + Arrays.fill(frame, 0f); + if (start < mono.length) System.arraycopy(mono, start, frame, 0, Math.min(FRAME_SAMPLES, mono.length - start)); + audio.add(encoder.encode(frame)); + } + + int serial = file.getName().hashCode(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int sequence = 0; + writePage(out, FLAG_FIRST, 0, serial, sequence++, List.of(opusHead(inputRate))); + writePage(out, 0, 0, serial, sequence++, List.of(opusTags())); + for (int first = 0; first < audio.size(); first += PACKETS_PER_PAGE) { + int last = Math.min(audio.size(), first + PACKETS_PER_PAGE); + boolean end = last == audio.size(); + long granule = end ? total : (long) last * FRAME_SAMPLES; + writePage(out, end ? FLAG_LAST : 0, granule, serial, sequence++, audio.subList(first, last)); + } + Files.write(file.toPath(), out.toByteArray()); + } + + private static byte[] opusHead(int inputRate) { + ByteBuffer b = ByteBuffer.allocate(19).order(ByteOrder.LITTLE_ENDIAN); + b.put("OpusHead".getBytes(StandardCharsets.US_ASCII)); + b.put((byte) 1); // version + b.put((byte) 1); // channels + b.putShort((short) PRE_SKIP); + b.putInt(inputRate); + b.putShort((short) 0); // output gain + b.put((byte) 0); // channel mapping family: mono/stereo + return b.array(); + } + + private static byte[] opusTags() { + byte[] vendor = "ts3j".getBytes(StandardCharsets.UTF_8); + ByteBuffer b = ByteBuffer.allocate(8 + 4 + vendor.length + 4).order(ByteOrder.LITTLE_ENDIAN); + b.put("OpusTags".getBytes(StandardCharsets.US_ASCII)); + b.putInt(vendor.length).put(vendor); + b.putInt(0); // no user comments + return b.array(); + } + + // ---- Ogg framing ---- + + private record Page(long granule, byte[] segments, byte[] data) { + } + + private static void writePage(ByteArrayOutputStream out, int flags, long granule, int serial, int sequence, + List packets) { + ByteArrayOutputStream lacing = new ByteArrayOutputStream(); + ByteArrayOutputStream data = new ByteArrayOutputStream(); + for (byte[] packet : packets) { + // A packet is split into 255-byte segments and ends with a shorter one (possibly empty). + int left = packet.length; + while (left >= 255) { + lacing.write(255); + left -= 255; + } + lacing.write(left); + data.write(packet, 0, packet.length); + } + ByteBuffer header = ByteBuffer.allocate(27 + lacing.size()).order(ByteOrder.LITTLE_ENDIAN); + header.put("OggS".getBytes(StandardCharsets.US_ASCII)); + header.put((byte) 0); + header.put((byte) flags); + header.putLong(granule); + header.putInt(serial); + header.putInt(sequence); + header.putInt(0); // checksum, filled in below + header.put((byte) lacing.size()); + header.put(lacing.toByteArray()); + + byte[] page = new byte[header.capacity() + data.size()]; + System.arraycopy(header.array(), 0, page, 0, header.capacity()); + System.arraycopy(data.toByteArray(), 0, page, header.capacity(), data.size()); + int crc = crc(page); + ByteBuffer.wrap(page).order(ByteOrder.LITTLE_ENDIAN).putInt(22, crc); + out.write(page, 0, page.length); + } + + private static List pages(byte[] file) throws IOException { + List pages = new ArrayList<>(); + ByteBuffer in = ByteBuffer.wrap(file).order(ByteOrder.LITTLE_ENDIAN); + while (in.remaining() >= 27) { + int start = in.position(); + if (in.getInt() != 0x5367674f) throw new IOException("lost Ogg page sync"); // "OggS" + in.position(start + 6); + long granule = in.getLong(); + in.position(start + 26); + int count = in.get() & 0xff; + byte[] segments = new byte[count]; + in.get(segments); + int size = 0; + for (byte s : segments) size += s & 0xff; + if (size > in.remaining()) throw new IOException("truncated Ogg page"); + byte[] data = new byte[size]; + in.get(data); + pages.add(new Page(granule, segments, data)); + } + if (pages.isEmpty()) throw new IOException("not an Ogg file"); + return pages; + } + + /** Joins the pages' segments into packets; a packet may run on into the next page. */ + private static List packets(List pages) { + List packets = new ArrayList<>(); + ByteArrayOutputStream packet = new ByteArrayOutputStream(); + for (Page page : pages) { + int offset = 0; + for (byte segment : page.segments) { + int size = segment & 0xff; + packet.write(page.data, offset, size); + offset += size; + if (size < 255) { + packets.add(packet.toByteArray()); + packet.reset(); + } + } + } + return packets; + } + + /** Ogg's CRC-32: polynomial 0x04c11db7, not reflected, starting from 0. */ + private static int crc(byte[] data) { + int crc = 0; + for (byte b : data) { + crc ^= (b & 0xff) << 24; + for (int i = 0; i < 8; i++) crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04c11db7 : crc << 1; + } + return crc; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java b/ts3-client/core/src/main/java/com/ts3client/sound/PackSoundPlayer.java similarity index 87% rename from ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java rename to ts3-client/core/src/main/java/com/ts3client/sound/PackSoundPlayer.java index ac58df3..190bb30 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/WavSoundPlayer.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/PackSoundPlayer.java @@ -3,6 +3,7 @@ package com.ts3client.sound; import com.ts3client.audio.AudioIo; import com.ts3client.audio.AudioPlayback; import com.ts3client.audio.VoiceFormat; +import com.ts3client.audio.opus.OpusCodec; import java.io.File; import java.util.ArrayList; @@ -10,17 +11,18 @@ import java.util.Arrays; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** - * Plays sound-pack wave files on the configured playback device. + * Plays sound-pack files (wave or Ogg Opus) on the configured playback device. * *

All notification sounds share one playback line and are mixed together, so * two events firing at once never fight over the device, and the line is dropped * again after a long enough quiet spell. Files are decoded once, resampled to the * device's 48 kHz and cached, so repeat events cost nothing but the mix. */ -public final class WavSoundPlayer implements SoundPlayer { +public final class PackSoundPlayer implements SoundPlayer { /** * How long the playback line is held after the last sound. Opening it costs a @@ -62,12 +64,14 @@ public final class WavSoundPlayer implements SoundPlayer { private final Object lock = new Object(); private final AudioIo io; + private final OpusCodec opus; private volatile String outputDevice; private volatile boolean running = true; private Thread mixer; - public WavSoundPlayer(AudioIo io, String outputDevice) { + public PackSoundPlayer(AudioIo io, OpusCodec opus, String outputDevice) { this.io = io; + this.opus = opus; this.outputDevice = outputDevice; } @@ -216,24 +220,10 @@ public final class WavSoundPlayer implements SoundPlayer { } /** Decodes a sound file to mono 48 kHz float samples. */ - private static float[] readMono48k(File file) throws Exception { - WaveFile.Audio audio = WaveFile.readMono(file); - return resample(audio.samples(), audio.sampleRate(), VoiceFormat.SAMPLE_RATE); - } - - private static float[] resample(float[] input, float fromRate, int toRate) { - if (fromRate <= 0 || Math.abs(fromRate - toRate) < 0.5f || input.length == 0) return input; - double ratio = toRate / (double) fromRate; - int length = (int) (input.length * ratio); - float[] out = new float[length]; - for (int i = 0; i < length; i++) { - double position = i / ratio; - int index = (int) position; - double fraction = position - index; - float a = input[Math.min(index, input.length - 1)]; - float b = input[Math.min(index + 1, input.length - 1)]; - out[i] = (float) (a + (b - a) * fraction); + private float[] readMono48k(File file) throws Exception { + if (file.getName().toLowerCase(Locale.ROOT).endsWith(OggOpusFile.EXTENSION)) { + return OggOpusFile.readMono(file, opus); } - return out; + return WaveFile.readMono(file).at48k(); } } diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java index 02c4278..b05d9f9 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java @@ -79,12 +79,21 @@ public final class SoundPack { /** * Resolves the wave file for a {@code play(...)} script. Pack authors write the * names as they appear on their (often case-insensitive) file system, so a - * mismatched case is retried against the directory listing. + * mismatched case is retried against the directory listing. A pack whose sounds + * were converted to Opus keeps its scripts: {@code x.wav} is found as {@code x.opus}. * * @return the file, or {@code null} if the pack does not contain it */ public File file(String fileName) { if (fileName == null || fileName.isEmpty()) return null; + File found = find(fileName); + if (found == null && fileName.regionMatches(true, fileName.length() - 4, ".wav", 0, 4)) { + found = find(fileName.substring(0, fileName.length() - 4) + OggOpusFile.EXTENSION); + } + return found; + } + + private File find(String fileName) { File direct = new File(directory, fileName); if (direct.isFile()) return direct; diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPackConverter.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPackConverter.java new file mode 100644 index 0000000..2ea293a --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPackConverter.java @@ -0,0 +1,54 @@ +package com.ts3client.sound; + +import com.ts3client.audio.VoiceFormat; +import com.ts3client.audio.opus.OpusCodec; +import com.ts3client.audio.opus.OpusEncoder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +/** + * Copies sound packs with their wave files encoded as Ogg Opus, about a fifteenth of + * the size. The scripts are left as they are: {@link SoundPack#file} finds a pack's + * {@code x.wav} as {@code x.opus}. + */ +public final class SoundPackConverter { + + /** Clear of any audible difference for notification sounds, at a fifteenth of the WAVs' size. */ + public static final int BITRATE = 64_000; + + private SoundPackConverter() { + } + + /** Converts every file under {@code source} into the same place under {@code target}. */ + public static void convert(File source, File target, OpusCodec opus) throws IOException { + File[] entries = source.listFiles(); + if (entries == null) throw new IOException("Cannot list " + source); + Files.createDirectories(target.toPath()); + for (File entry : entries) { + String name = entry.getName(); + if (entry.isDirectory()) { + convert(entry, new File(target, name), opus); + } else if (name.toLowerCase(Locale.ROOT).endsWith(".wav")) { + File out = new File(target, name.substring(0, name.length() - 4) + OggOpusFile.EXTENSION); + encode(entry, out, opus); + } else { + Files.copy(entry.toPath(), new File(target, name).toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private static void encode(File wave, File out, OpusCodec opus) throws IOException { + WaveFile.Audio audio = WaveFile.readMono(wave); + try (OpusEncoder encoder = opus.createEncoder(VoiceFormat.SAMPLE_RATE, VoiceFormat.FRAME_SIZE, 1, + OpusCodec.Application.AUDIO)) { + encoder.setBitrate(BITRATE); + encoder.setVbr(true); + encoder.setComplexity(10); + OggOpusFile.write(out, audio.at48k(), audio.sampleRate(), encoder); + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java b/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java index 722437b..48cfee8 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/WaveFile.java @@ -1,5 +1,7 @@ package com.ts3client.sound; +import com.ts3client.audio.VoiceFormat; + import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; @@ -19,6 +21,23 @@ final class WaveFile { /** Decoded audio: mono samples in [-1, 1] at {@code sampleRate}. */ record Audio(float[] samples, int sampleRate) { + + /** The samples at the 48 kHz everything plays at, linearly interpolated. */ + float[] at48k() { + int toRate = VoiceFormat.SAMPLE_RATE; + if (sampleRate <= 0 || sampleRate == toRate || samples.length == 0) return samples; + double ratio = toRate / (double) sampleRate; + float[] out = new float[(int) (samples.length * ratio)]; + for (int i = 0; i < out.length; i++) { + double position = i / ratio; + int index = (int) position; + double fraction = position - index; + float a = samples[Math.min(index, samples.length - 1)]; + float b = samples[Math.min(index + 1, samples.length - 1)]; + out[i] = (float) (a + (b - a) * fraction); + } + return out; + } } private WaveFile() { diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/EncodeSoundPacks.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/EncodeSoundPacks.java new file mode 100644 index 0000000..536ff29 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/EncodeSoundPacks.java @@ -0,0 +1,26 @@ +package com.ts3client.audio.desktop; + +import com.ts3client.sound.SoundPackConverter; + +import java.io.File; +import java.io.IOException; + +/** + * Converts a folder of sound packs to Opus with the bundled libopus — the Android build + * ships TeamSpeak's packs this way. + * + *

Usage: {@code EncodeSoundPacks } + */ +public final class EncodeSoundPacks { + + private EncodeSoundPacks() { + } + + public static void main(String[] args) throws IOException { + if (args.length != 2) { + System.err.println("Usage: EncodeSoundPacks "); + System.exit(2); + } + SoundPackConverter.convert(new File(args[0]), new File(args[1]), new NativeOpusCodec()); + } +} diff --git a/ts3-client/desktop/src/test/java/com/ts3client/sound/OggOpusFileTest.java b/ts3-client/desktop/src/test/java/com/ts3client/sound/OggOpusFileTest.java new file mode 100644 index 0000000..a080f63 --- /dev/null +++ b/ts3-client/desktop/src/test/java/com/ts3client/sound/OggOpusFileTest.java @@ -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()); + } +}