Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitmodules

This file was deleted.

1 change: 0 additions & 1 deletion lib/atrac9j
Submodule atrac9j deleted from 13a2e3
6 changes: 0 additions & 6 deletions odradek-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
<artifactId>odradek-core</artifactId>

<dependencies>
<!-- Internal dependencies -->
<dependency>
<groupId>sh.adelessfox</groupId>
<artifactId>atrac9j</artifactId>
</dependency>

<!-- External dependencies -->
<dependency>
<groupId>at.yawk.lz4</groupId>
Expand Down
4 changes: 2 additions & 2 deletions odradek-core/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
requires static java.desktop; // sh.adelessfox.odradek.texture.Converter.AWT

requires be.twofold.tinybcdec;
requires java.net.http;
requires org.lz4.java;
requires org.slf4j;
requires sh.adelessfox.atrac9j;

exports sh.adelessfox.odradek.audio.codec;
exports sh.adelessfox.odradek.audio.container.at9;
exports sh.adelessfox.odradek.audio.container.riff;
exports sh.adelessfox.odradek.audio.container.wave;
exports sh.adelessfox.odradek.audio.container.wwise;
exports sh.adelessfox.odradek.audio;
exports sh.adelessfox.odradek.compression;
exports sh.adelessfox.odradek.event;
Expand Down
14 changes: 10 additions & 4 deletions odradek-core/src/main/java/sh/adelessfox/odradek/audio/Audio.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package sh.adelessfox.odradek.audio;

import sh.adelessfox.odradek.audio.codec.AudioCodec;

/**
* Audio data container.
*
Expand All @@ -17,7 +15,15 @@ public record Audio(AudioCodec codec, AudioFormat format, int samples, byte[] da
}
}

public Audio toPcm16() {
return codec.toPcm16(format, data);
public Audio convert(AudioCodec codec, AudioFormat format) {
return AudioConverter.convert(this, codec, format);
}

public Audio convert(AudioCodec codec, int channels) {
return AudioConverter.convert(this, codec, new AudioFormat(format.sampleRate(), channels));
}

public Audio convert(AudioCodec codec) {
return convert(codec, format);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package sh.adelessfox.odradek.audio;

public sealed interface AudioCodec {
enum Pcm implements AudioCodec {
/** PCM 16-bit signed little-endian */
S16LE,
/** PCM 24-bit signed little-endian */
S24LE,
/** PCM 32-bit signed little-endian */
S32LE,
/** PCM 32-bit float little-endian */
F32LE;

public int sizeBytes() {
return switch (this) {
case S16LE -> 2;
case S24LE -> 3;
case S32LE, F32LE -> 4;
};
}
}

record Atrac9() implements AudioCodec {
}

record Wwise() implements AudioCodec {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package sh.adelessfox.odradek.audio;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sh.adelessfox.odradek.audio.container.riff.RiffParser;
import sh.adelessfox.odradek.audio.container.wave.WaveDataChunk;
import sh.adelessfox.odradek.audio.container.wave.WaveFmtChunk;
import sh.adelessfox.odradek.io.BinaryReader;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

final class AudioConverter {
private static final Logger log = LoggerFactory.getLogger(AudioConverter.class);

private AudioConverter() {
}

static Audio convert(Audio audio, AudioCodec codec, AudioFormat format) {
Audio converted = audio;
converted = transcode(converted, codec);
converted = remix(converted, (AudioCodec.Pcm) codec, format);
return converted;
}

private static Audio transcode(Audio audio, AudioCodec codec) {
if (audio.codec().equals(codec)) {
return audio;
}
if (!(codec instanceof AudioCodec.Pcm pcm)) {
// Only PCM conversion is supported
throw new IllegalArgumentException("Unsupported codec: " + codec);
}
return convertPcm(audio, pcm);
}

private static Audio remix(Audio audio, AudioCodec.Pcm codec, AudioFormat format) {
if (audio.format().equals(format)) {
return audio;
}

int sourceChannels = audio.format().channels();
int targetChannels = format.channels();
if (sourceChannels != targetChannels) {
var buffer = remap(audio.data(), audio.samples(), codec.sizeBytes(), sourceChannels, targetChannels);
return new Audio(codec, format, audio.samples(), buffer);
}

return new Audio(codec, format, audio.samples(), audio.data());
}

private static byte[] remap(
byte[] data,
int sampleCount,
int sampleSize,
int sourceChannels,
int targetChannels
) {
var buffer = new byte[sampleCount * sampleSize * targetChannels];
for (int i = 0; i < sampleCount; i++) {
for (int ch = 0; ch < targetChannels; ch++) {
int srcCh = ch % sourceChannels;
System.arraycopy(
data, (i * sourceChannels + srcCh) * sampleSize,
buffer, (i * targetChannels + ch) * sampleSize,
sampleSize);
}
}
return buffer;
}

// region vgmstream
private static Audio convertPcm(Audio audio, AudioCodec.Pcm target) {
try {
return unpackWavToAudio(convertToWav(audio, target), target);
} catch (Exception e) {
throw new RuntimeException("Failed to convert audio", e);
}
}

private static byte[] convertToWav(Audio audio, AudioCodec.Pcm target) throws IOException, InterruptedException {
var vgmstream = VgmstreamDownloader.download();
var input = extractTempFile(audio);
var command = buildVgmstreamCommand(vgmstream, input, target);

try {
var process = new ProcessBuilder()
.command(command)
.start();

byte[] stdout;
try (InputStream in = process.getInputStream()) {
stdout = in.readAllBytes();
}

byte[] stderr;
try (InputStream in = process.getErrorStream()) {
stderr = in.readAllBytes();
}

int status = process.waitFor();
if (status != 0) {
log.error("vgmstream conversion failed with status {}, error: {}", status, new String(stderr, StandardCharsets.UTF_8));
throw new IllegalStateException("vgmstream conversion failed with status " + status);
}

if (stderr.length > 0) {
log.warn("vgmstream conversion produced warnings: {}", new String(stderr, StandardCharsets.UTF_8));
}

return stdout;
} finally {
try {
Files.deleteIfExists(input);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", input, e);
}
}
}

private static List<String> buildVgmstreamCommand(Path vgmstream, Path file, AudioCodec.Pcm target) {
var format = switch (target) {
case S16LE -> "-W1"; // PCM16
case S24LE -> "-W2"; // PCM24
case S32LE -> "-W3"; // PCM32
case F32LE -> "-W4"; // float
};
return List.of(
vgmstream.toAbsolutePath().toString(),
file.toAbsolutePath().toString(),
"-i", // ignore looping information
"-p", // output to stdout
"-P", // output to stdout even if stdout is a terminal
format);
}

private static Audio unpackWavToAudio(byte[] wav, AudioCodec.Pcm target) throws IOException {
var riff = new RiffParser()
.reader(WaveFmtChunk.ID, WaveFmtChunk.reader())
.reader(WaveDataChunk.ID, WaveDataChunk.reader())
.parse(BinaryReader.wrap(wav));

var fmt = riff.get(WaveFmtChunk.ID).orElseThrow(() -> new IllegalStateException("Missing fmt chunk"));
var data = riff.get(WaveDataChunk.ID).orElseThrow(() -> new IllegalStateException("Missing data chunk"));

int sampleRate = fmt.sampleRate();
int channels = fmt.channelCount();
int samples = data.data().length / (target.sizeBytes() * channels);

return new Audio(target, new AudioFormat(sampleRate, channels), samples, data.data());
}

private static Path extractTempFile(Audio audio) {
var suffix = switch (audio.codec()) {
case AudioCodec.Atrac9 _ -> "input.at9";
case AudioCodec.Wwise _ -> "input.wem";
case AudioCodec.Pcm _ -> throw new IllegalArgumentException("PCM audio cannot be converted to PCM");
};
try {
var file = Files.createTempFile("odradek-vgstream", suffix);
Files.write(file, audio.data());
return file;
} catch (IOException e) {
log.error("Failed to extract audio to temporary file", e);
throw new UncheckedIOException(e);
}
}
// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ public record AudioFormat(int sampleRate, int channels) {
if (sampleRate <= 0) {
throw new IllegalArgumentException("sampleRate must be positive");
}
if (channels != 1 && channels != 2) {
throw new IllegalArgumentException("channels must be 1 (mono) or 2 (stereo)");
if (channels <= 0) {
throw new IllegalArgumentException("channels must be positive");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package sh.adelessfox.odradek.audio;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sh.adelessfox.odradek.util.system.OperatingSystem;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

final class VgmstreamDownloader {
private static final Logger log = LoggerFactory.getLogger(VgmstreamDownloader.class);

private static final Set<PosixFilePermission> EXECUTABLE_PERMISSIONS = PosixFilePermissions.fromString("rwxr-xr-x");

private static final String VERSION = "r2083";
private static final String PREFIX = "https://github.com/vgmstream/vgmstream/releases/download/" + VERSION + "/";
private static final String VGMSTREAM_WINDOWS_AMD64 = PREFIX + "vgmstream-win64.zip";
private static final String VGMSTREAM_LINUX_AMD64 = PREFIX + "vgmstream-linux-cli.zip";
private static final String VGMSTREAM_MACOS_ARM64 = PREFIX + "vgmstream-mac-cli.zip";

private VgmstreamDownloader() {
}

static Path download() {
var string = switch (OperatingSystem.name()) {
case WINDOWS -> switch (OperatingSystem.arch()) {
case AMD64 -> VGMSTREAM_WINDOWS_AMD64;
default -> throw new UnsupportedOperationException(OperatingSystem.arch().name());
};
case LINUX -> switch (OperatingSystem.arch()) {
case AMD64 -> VGMSTREAM_LINUX_AMD64;
default -> throw new UnsupportedOperationException(OperatingSystem.arch().name());
};
case MACOS -> switch (OperatingSystem.arch()) {
case AARCH64 -> VGMSTREAM_MACOS_ARM64;
default -> throw new UnsupportedOperationException(OperatingSystem.arch().name());
};
};
var directory = Path.of("vgmstream-" + VERSION);
if (!Files.exists(directory)) {
downloadZip(URI.create(string), directory);
}
var executable = directory.resolve(switch (OperatingSystem.name()) {
case WINDOWS -> "vgmstream-cli.exe";
case LINUX, MACOS -> "vgmstream-cli";
});
if (!Files.isExecutable(executable)) {
try {
Files.setPosixFilePermissions(executable, EXECUTABLE_PERMISSIONS);
} catch (Exception e) {
log.error("Failed to set executable permissions for {}", executable, e);
}
}
return executable;
}

private static void downloadZip(URI uri, Path path) {
var client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();

try (client) {
var request = HttpRequest.newBuilder()
.uri(uri)
.build();

log.info("Downloading vgmstream from {}", uri);
var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new Exception("HTTP error: " + response.statusCode());
}

log.info("Unpacking to {}", path);
try (var in = new ZipInputStream(response.body())) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
log.info("Extracting {}", entry.getName());

var target = path.resolve(entry.getName());
Files.createDirectories(target.getParent());
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
} catch (Exception e) {
log.error("Failed to download {}", uri, e);
throw new RuntimeException(e);
}
}
}

This file was deleted.

Loading
Loading