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()); + } +}