diff --git a/build.gradle b/build.gradle index ee2e140..17d7a91 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ plugins { id 'org.jetbrains.kotlin.jvm' version "${kotlin_version}" id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlin_version}" id 'org.jetbrains.dokka' version "1.9.10" + id 'com.github.johnrengelman.shadow' version '8.1.1' } java.toolchain.languageVersion = JavaLanguageVersion.of(17) @@ -22,6 +23,7 @@ version = "${minecraft_version}-${mod_version}" ////////////////// repositories { + mavenCentral() maven { name 'Kotlin for Forge' url 'https://thedarkcolour.github.io/KotlinForForge/' @@ -29,17 +31,33 @@ repositories { } } +configurations { + shade + implementation.extendsFrom shade +} + dependencies { minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" annotationProcessor 'org.spongepowered:mixin:0.8.5:processor' implementation "thedarkcolour:kotlinforforge:${kotlinforforge_version}" + + shade "com.googlecode.soundlibs:jlayer:1.0.1.4" } minecraft { mappings channel: mappings_channel, version: mappings_version runs { + configureEach { + lazyToken('minecraft_classpath') { + configurations.shade + .copyRecursive() + .resolve() + .collect { it.absolutePath } + .join(File.pathSeparator) + } + } client { property 'forge.logging.markers', 'REGISTRIES' property 'forge.logging.console.level', 'debug' @@ -89,21 +107,37 @@ processResources { } } +shadowJar { + archiveClassifier = '' + configurations = [project.configurations.shade] + + relocate 'javazoom', 'com.hemogoblins.betterrecords.shadow.javazoom' + + finalizedBy 'reobfShadowJar' +} + +reobf { + shadowJar {} +} + +tasks.named('jar') { + archiveClassifier = 'slim' +} + +assemble.dependsOn shadowJar + task deobfJar(type: Jar) { from sourceSets.main.output - //noinspection GroovyAccessibility archiveClassifier = 'deobf' } task sourcesJar(type: Jar) { from sourceSets.main.allJava - //noinspection GroovyAccessibility archiveClassifier = 'sources' } task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir - //noinspection GroovyAccessibility archiveClassifier = 'javadoc' } diff --git a/src/main/java/com/hemogoblins/betterrecords/mixin/MixinSoundEngine.java b/src/main/java/com/hemogoblins/betterrecords/mixin/MixinSoundEngine.java index a3bc148..c3148b0 100644 --- a/src/main/java/com/hemogoblins/betterrecords/mixin/MixinSoundEngine.java +++ b/src/main/java/com/hemogoblins/betterrecords/mixin/MixinSoundEngine.java @@ -2,12 +2,13 @@ import com.hemogoblins.betterrecords.BRConfig; import com.hemogoblins.betterrecords.BetterRecords; +import com.hemogoblins.betterrecords.api.client.sound.AudioStreamRegistry; import com.hemogoblins.betterrecords.api.client.sound.FileSoundInstance; +import com.hemogoblins.betterrecords.api.client.sound.RecordAudioStream; import com.hemogoblins.betterrecords.client.sound.ProgressInputStream; import com.hemogoblins.betterrecords.client.sound.RecordSoundManager; import com.mojang.blaze3d.audio.Channel; import com.mojang.blaze3d.audio.Library; -import com.mojang.blaze3d.audio.OggAudioStream; import com.mojang.blaze3d.audio.SoundBuffer; import net.minecraft.Util; import net.minecraft.client.Minecraft; @@ -110,7 +111,9 @@ public void onPlay(SoundInstance sound, CallbackInfo ci) { }); } - /** Reads and decodes an OGG file into a static buffer. Runs off the main thread. */ + /** + * Reads and decodes a cached audio file into a static buffer. + */ @Unique private SoundBuffer betterRecords$decode(FileSoundInstance fileSound, File soundFile) { if (!soundFile.exists()) { @@ -119,8 +122,8 @@ public void onPlay(SoundInstance sound, CallbackInfo ci) { try (FileInputStream stream = new FileInputStream(soundFile); ProgressInputStream progressStream = new ProgressInputStream(stream, soundFile.length(), fileSound); - OggAudioStream oggStream = new OggAudioStream(progressStream)) { - SoundBuffer soundBuffer = new SoundBuffer(oggStream.readAll(), oggStream.getFormat()); + RecordAudioStream audioStream = AudioStreamRegistry.INSTANCE.open(progressStream)) { + SoundBuffer soundBuffer = new SoundBuffer(audioStream.readAll(), audioStream.getFormat()); fileSound.setLoadProgress(1.0F); return soundBuffer; } catch (Exception e) { diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/BetterRecords.kt b/src/main/kotlin/com/hemogoblins/betterrecords/BetterRecords.kt index c771420..0a1ad75 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/BetterRecords.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/BetterRecords.kt @@ -1,11 +1,14 @@ package com.hemogoblins.betterrecords import com.hemogoblins.betterrecords.api.client.MusicCache +import com.hemogoblins.betterrecords.api.client.sound.AudioStreamRegistry import com.hemogoblins.betterrecords.block.ModBlocks import com.hemogoblins.betterrecords.block.renderer.ModRenderers import com.hemogoblins.betterrecords.capability.ModCapabilities import com.hemogoblins.betterrecords.client.cache.FilesystemCache import com.hemogoblins.betterrecords.client.gui.ModOverlays +import com.hemogoblins.betterrecords.client.sound.format.Mp3AudioStreamProvider +import com.hemogoblins.betterrecords.client.sound.format.OggAudioStreamProvider import com.hemogoblins.betterrecords.client.screen.ModScreens import com.hemogoblins.betterrecords.item.ModItems import com.hemogoblins.betterrecords.menu.ModMenuTypes @@ -68,6 +71,8 @@ object BetterRecords { * Client side setup events */ private fun onClientSetup(event: FMLClientSetupEvent) { + AudioStreamRegistry.register(OggAudioStreamProvider) + AudioStreamRegistry.register(Mp3AudioStreamProvider) } /** diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamProvider.kt b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamProvider.kt new file mode 100644 index 0000000..956516d --- /dev/null +++ b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamProvider.kt @@ -0,0 +1,24 @@ +package com.hemogoblins.betterrecords.api.client.sound + +import java.io.IOException +import java.io.InputStream + +interface AudioStreamProvider { + + /** Short, lowercase identifier for the format, e.g. `"ogg"` or `"mp3"`. */ + val id: String + + /** + * Whether this provider can decode a stream that begins with [header]. + * + * [header] holds the first few bytes of the stream (see [AudioStreamRegistry.HEADER_LENGTH]); + * Implementations should sniff magic bytes to determine format. + */ + fun matches(header: ByteArray): Boolean + + /** + * Wraps [input] in a [RecordAudioStream] that decodes it to PCM. + */ + @Throws(IOException::class) + fun create(input: InputStream): RecordAudioStream +} diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamRegistry.kt b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamRegistry.kt new file mode 100644 index 0000000..46dc8cc --- /dev/null +++ b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/AudioStreamRegistry.kt @@ -0,0 +1,59 @@ +package com.hemogoblins.betterrecords.api.client.sound + +import com.hemogoblins.betterrecords.BetterRecords +import java.io.IOException +import java.io.InputStream +import java.io.PushbackInputStream +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Registry of [AudioStreamProvider]s, used to pick the right decoder for a cached + * audio file at playback time. + * + * Detection is done by sniffing the first [HEADER_LENGTH] bytes of the stream, so + * the format does not have to be known ahead of time + */ +object AudioStreamRegistry { + + /** Number of leading bytes handed to [AudioStreamProvider.matches] for sniffing. */ + const val HEADER_LENGTH = 4 + + private val providers = CopyOnWriteArrayList() + + /** Registers [provider]. Providers are checked in registration order. */ + fun register(provider: AudioStreamProvider) { + providers.add(provider) + BetterRecords.logger.info("Registered audio stream provider: {}", provider.id) + } + + /** All registered providers. */ + fun providers(): List = providers.toList() + + /** + * Sniffs [input] and wraps it in a [RecordAudioStream] using the first provider that [AudioStreamProvider.matches] its header. + * + * @throws IOException if no registered provider supports the stream. + */ + @Throws(IOException::class) + fun open(input: InputStream): RecordAudioStream { + val pushback = PushbackInputStream(input, HEADER_LENGTH) + + val header = ByteArray(HEADER_LENGTH) + var read = 0 + while (read < HEADER_LENGTH) { + val r = pushback.read(header, read, HEADER_LENGTH - read) + if (r < 0) break + read += r + } + if (read > 0) pushback.unread(header, 0, read) + + val sniffed = if (read == HEADER_LENGTH) header else header.copyOf(read) + val provider = providers.firstOrNull { it.matches(sniffed) } + ?: throw IOException( + "No audio stream provider for header [${sniffed.joinToString(" ") { "%02X".format(it) }}]" + ) + + BetterRecords.logger.debug("Decoding record audio using '{}' provider", provider.id) + return provider.create(pushback) + } +} diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/RecordAudioStream.kt b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/RecordAudioStream.kt new file mode 100644 index 0000000..1151f0d --- /dev/null +++ b/src/main/kotlin/com/hemogoblins/betterrecords/api/client/sound/RecordAudioStream.kt @@ -0,0 +1,24 @@ +package com.hemogoblins.betterrecords.api.client.sound + +import java.io.Closeable +import java.io.IOException +import java.nio.ByteBuffer +import javax.sound.sampled.AudioFormat + +/** + * A source of decoded, signed 16-bit PCM audio ready for OpenAL. + */ +interface RecordAudioStream : Closeable { + + /** + * The format of the PCM returned by [readAll]. Channels must be mono or stereo + * and the sample size 8 or 16 bit, those are the only layouts OpenAL accepts. + */ + val format: AudioFormat + + /** + * Fully decodes the stream into a single direct [ByteBuffer] of PCM + */ + @Throws(IOException::class) + fun readAll(): ByteBuffer +} diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt index 72b2e07..7c751fa 100644 --- a/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt @@ -27,7 +27,7 @@ class RecordEtcherScreen( val etchButton = Button.builder(Component.translatable("menu.${BetterRecords.ID}.record_etcher.etch")) { try { // TODO URL From text field - val url = "https://betterrecords-files.s3.amazonaws.com/fastcar.ogg" + val url = "https://betterrecords-files.s3.amazonaws.com/fastcar.mp3" val cacheEntry = BetterRecords.cache.get(url ) { progressPercent -> // TODO: Progress Bar diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/Mp3AudioStreamProvider.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/Mp3AudioStreamProvider.kt new file mode 100644 index 0000000..151f676 --- /dev/null +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/Mp3AudioStreamProvider.kt @@ -0,0 +1,107 @@ +package com.hemogoblins.betterrecords.client.sound.format + +import com.hemogoblins.betterrecords.api.client.sound.AudioStreamProvider +import com.hemogoblins.betterrecords.api.client.sound.RecordAudioStream +import javazoom.jl.decoder.Bitstream +import javazoom.jl.decoder.BitstreamException +import javazoom.jl.decoder.Decoder +import javazoom.jl.decoder.DecoderException +import javazoom.jl.decoder.Header +import javazoom.jl.decoder.SampleBuffer +import org.lwjgl.BufferUtils +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import javax.sound.sampled.AudioFormat + +/** + * MP3 support, backed by the JLayer decoder. + * + * JLayer decodes MP3 frames into interleaved signed 16-bit PCM, which is exactly what OpenAL wants. + */ +object Mp3AudioStreamProvider : AudioStreamProvider { + + override val id = "mp3" + + override fun matches(header: ByteArray): Boolean { + // ID3v2-tagged mp3 files start with the literal "ID3". + if (header.size >= 3 && + header[0] == 'I'.code.toByte() && + header[1] == 'D'.code.toByte() && + header[2] == '3'.code.toByte() + ) { + return true + } + // Otherwise the file starts straight with an MPEG audio frame: 11 sync bits set. + return header.size >= 2 && + header[0] == 0xFF.toByte() && + (header[1].toInt() and 0xE0) == 0xE0 + } + + override fun create(input: InputStream): RecordAudioStream = Mp3AudioStream(input) + + private class Mp3AudioStream(input: InputStream) : RecordAudioStream { + + private val bitstream = Bitstream(input) + private val decoder = Decoder() + private var decodedFormat: AudioFormat? = null + + override val format: AudioFormat + get() = decodedFormat + ?: throw IllegalStateException("readAll() must be called before the format is known") + + @Throws(IOException::class) + override fun readAll(): ByteBuffer { + val chunks = ArrayList() + var totalShorts = 0 + var sampleRate = -1 + var channels = -1 + + try { + var header: Header? = bitstream.readFrame() + while (header != null) { + val output = decoder.decodeFrame(header, bitstream) as SampleBuffer + if (sampleRate < 0) { + sampleRate = output.sampleFrequency + channels = output.channelCount + } + + val length = output.bufferLength + chunks.add(output.buffer.copyOf(length)) + totalShorts += length + + bitstream.closeFrame() + header = bitstream.readFrame() + } + } catch (e: BitstreamException) { + throw IOException("Failed to read MP3 stream", e) + } catch (e: DecoderException) { + throw IOException("Failed to decode MP3 stream", e) + } + + if (sampleRate < 0 || channels < 1) { + throw IOException("MP3 stream contained no decodable audio frames") + } + + // Signed 16-bit, little-endian (native order) PCM — the layout OpenAL expects. + decodedFormat = AudioFormat(sampleRate.toFloat(), 16, channels, true, false) + + val buffer = BufferUtils.createByteBuffer(totalShorts * 2) + for (chunk in chunks) { + for (sample in chunk) { + buffer.putShort(sample) + } + } + buffer.flip() + return buffer + } + + override fun close() { + try { + bitstream.close() + } catch (e: BitstreamException) { + throw IOException("Failed to close MP3 stream", e) + } + } + } +} diff --git a/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/OggAudioStreamProvider.kt b/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/OggAudioStreamProvider.kt new file mode 100644 index 0000000..09af988 --- /dev/null +++ b/src/main/kotlin/com/hemogoblins/betterrecords/client/sound/format/OggAudioStreamProvider.kt @@ -0,0 +1,33 @@ +package com.hemogoblins.betterrecords.client.sound.format + +import com.hemogoblins.betterrecords.api.client.sound.AudioStreamProvider +import com.hemogoblins.betterrecords.api.client.sound.RecordAudioStream +import com.mojang.blaze3d.audio.OggAudioStream +import java.io.InputStream +import java.nio.ByteBuffer +import javax.sound.sampled.AudioFormat + +/** + * Ogg Vorbis support, backed by Minecraft's [OggAudioStream] (stb_vorbis). + */ +object OggAudioStreamProvider : AudioStreamProvider { + + override val id = "ogg" + + // Every Ogg bitstream page begins with the capture pattern "OggS". + override fun matches(header: ByteArray): Boolean = + header.size >= 4 && + header[0] == 'O'.code.toByte() && + header[1] == 'g'.code.toByte() && + header[2] == 'g'.code.toByte() && + header[3] == 'S'.code.toByte() + + override fun create(input: InputStream): RecordAudioStream = + OggRecordAudioStream(OggAudioStream(input)) + + private class OggRecordAudioStream(private val delegate: OggAudioStream) : RecordAudioStream { + override val format: AudioFormat get() = delegate.format + override fun readAll(): ByteBuffer = delegate.readAll() + override fun close() = delegate.close() + } +}