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:
@@ -4,20 +4,28 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies the sound packs of an installed TeamSpeak 3 client into the app's assets, as the
|
* Puts 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
|
* desktop client borrows them from the installation, with the sounds encoded as Opus
|
||||||
* repository; a build without the client ships no packs.
|
* (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() {
|
abstract class BundleSoundPacks : DefaultTask() {
|
||||||
@get:InputFiles
|
@get:InputFiles
|
||||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||||
abstract val packs: DirectoryProperty
|
abstract val packs: DirectoryProperty
|
||||||
|
|
||||||
|
/** The desktop code whose libopus does the encoding. */
|
||||||
|
@get:Classpath
|
||||||
|
abstract val encoder: ConfigurableFileCollection
|
||||||
|
|
||||||
@get:OutputDirectory
|
@get:OutputDirectory
|
||||||
abstract val assets: DirectoryProperty
|
abstract val assets: DirectoryProperty
|
||||||
|
|
||||||
|
@get:Inject
|
||||||
|
abstract val exec: ExecOperations
|
||||||
|
|
||||||
@TaskAction
|
@TaskAction
|
||||||
fun copy() {
|
fun encode() {
|
||||||
val out = assets.get().asFile
|
val out = assets.get().asFile
|
||||||
out.deleteRecursively()
|
out.deleteRecursively()
|
||||||
val source = packs.get().asFile
|
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")
|
logger.warn("No TeamSpeak 3 sound packs at $source; the app ships without sounds")
|
||||||
return
|
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>("bundleSoundPacks") {
|
val bundleSoundPacks = tasks.register<BundleSoundPacks>("bundleSoundPacks") {
|
||||||
packs.set(File(ts3ClientDir, "sound"))
|
packs.set(File(ts3ClientDir, "sound"))
|
||||||
|
encoder.from(soundPackEncoder)
|
||||||
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
|
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +109,7 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":audio"))
|
implementation(project(":audio"))
|
||||||
|
soundPackEncoder(project(":desktop"))
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
|||||||
20
android/desktop/build.gradle.kts
Normal file
20
android/desktop/build.gradle.kts
Normal file
@@ -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<JavaCompile>().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"))
|
||||||
|
}
|
||||||
@@ -16,5 +16,5 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ts3-android"
|
rootProject.name = "ts3-android"
|
||||||
|
|
||||||
// core and ts3j compile the very sources the desktop's Maven build does.
|
// core, ts3j and desktop compile the very sources the desktop's Maven build does.
|
||||||
include(":ts3j", ":core", ":audio", ":app")
|
include(":ts3j", ":core", ":desktop", ":audio", ":app")
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ core/ com.ts3client
|
|||||||
│ ├── NotificationSettings per-action enabled / important flags
|
│ ├── NotificationSettings per-action enabled / important flags
|
||||||
│ ├── SoundNotifier decides what is heard (pack + config + mute state)
|
│ ├── SoundNotifier decides what is heard (pack + config + mute state)
|
||||||
│ ├── SoundPlayer renders a sound
|
│ ├── 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
|
└── teamspeak the official client's own data files
|
||||||
├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
|
├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
|
||||||
├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
|
├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.ts3client.audio;
|
|||||||
import com.ts3client.audio.opus.OpusCodec;
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.sound.SoundPlayer;
|
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
|
* 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. */
|
/** Player for notification sounds (sound packs); shared by all connections. */
|
||||||
default SoundPlayer createSoundPlayer(Settings settings) {
|
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. */
|
/** Human-readable codec/backend description, e.g. for an "about" line. */
|
||||||
|
|||||||
@@ -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<Page> pages = pages(Files.readAllBytes(file.toPath()));
|
||||||
|
List<byte[]> 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<byte[]> 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<byte[]> 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<Page> pages(byte[] file) throws IOException {
|
||||||
|
List<Page> 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<byte[]> packets(List<Page> pages) {
|
||||||
|
List<byte[]> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.ts3client.sound;
|
|||||||
import com.ts3client.audio.AudioIo;
|
import com.ts3client.audio.AudioIo;
|
||||||
import com.ts3client.audio.AudioPlayback;
|
import com.ts3client.audio.AudioPlayback;
|
||||||
import com.ts3client.audio.VoiceFormat;
|
import com.ts3client.audio.VoiceFormat;
|
||||||
|
import com.ts3client.audio.opus.OpusCodec;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -10,17 +11,18 @@ import java.util.Arrays;
|
|||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
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.
|
||||||
*
|
*
|
||||||
* <p>All notification sounds share one playback line and are mixed together, so
|
* <p>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
|
* 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
|
* 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.
|
* 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
|
* 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 Object lock = new Object();
|
||||||
|
|
||||||
private final AudioIo io;
|
private final AudioIo io;
|
||||||
|
private final OpusCodec opus;
|
||||||
private volatile String outputDevice;
|
private volatile String outputDevice;
|
||||||
private volatile boolean running = true;
|
private volatile boolean running = true;
|
||||||
private Thread mixer;
|
private Thread mixer;
|
||||||
|
|
||||||
public WavSoundPlayer(AudioIo io, String outputDevice) {
|
public PackSoundPlayer(AudioIo io, OpusCodec opus, String outputDevice) {
|
||||||
this.io = io;
|
this.io = io;
|
||||||
|
this.opus = opus;
|
||||||
this.outputDevice = outputDevice;
|
this.outputDevice = outputDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,24 +220,10 @@ public final class WavSoundPlayer implements SoundPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Decodes a sound file to mono 48 kHz float samples. */
|
/** Decodes a sound file to mono 48 kHz float samples. */
|
||||||
private static float[] readMono48k(File file) throws Exception {
|
private float[] readMono48k(File file) throws Exception {
|
||||||
WaveFile.Audio audio = WaveFile.readMono(file);
|
if (file.getName().toLowerCase(Locale.ROOT).endsWith(OggOpusFile.EXTENSION)) {
|
||||||
return resample(audio.samples(), audio.sampleRate(), VoiceFormat.SAMPLE_RATE);
|
return OggOpusFile.readMono(file, opus);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return out;
|
return WaveFile.readMono(file).at48k();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,12 +79,21 @@ public final class SoundPack {
|
|||||||
/**
|
/**
|
||||||
* Resolves the wave file for a {@code play(...)} script. Pack authors write the
|
* 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
|
* 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
|
* @return the file, or {@code null} if the pack does not contain it
|
||||||
*/
|
*/
|
||||||
public File file(String fileName) {
|
public File file(String fileName) {
|
||||||
if (fileName == null || fileName.isEmpty()) return null;
|
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);
|
File direct = new File(directory, fileName);
|
||||||
if (direct.isFile()) return direct;
|
if (direct.isFile()) return direct;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ts3client.sound;
|
package com.ts3client.sound;
|
||||||
|
|
||||||
|
import com.ts3client.audio.VoiceFormat;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
@@ -19,6 +21,23 @@ final class WaveFile {
|
|||||||
|
|
||||||
/** Decoded audio: mono samples in [-1, 1] at {@code sampleRate}. */
|
/** Decoded audio: mono samples in [-1, 1] at {@code sampleRate}. */
|
||||||
record Audio(float[] samples, int 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() {
|
private WaveFile() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Usage: {@code EncodeSoundPacks <source folder> <target folder>}
|
||||||
|
*/
|
||||||
|
public final class EncodeSoundPacks {
|
||||||
|
|
||||||
|
private EncodeSoundPacks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
if (args.length != 2) {
|
||||||
|
System.err.println("Usage: EncodeSoundPacks <source folder> <target folder>");
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
SoundPackConverter.convert(new File(args[0]), new File(args[1]), new NativeOpusCodec());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user