Skip to content

Commit 1d3a99d

Browse files
Merge pull request #46 from AngrySoundTech/mp3
Support loading mp3 streams
2 parents 40d040b + 1c20943 commit 1d3a99d

9 files changed

Lines changed: 297 additions & 8 deletions

File tree

build.gradle

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
id 'org.jetbrains.kotlin.jvm' version "${kotlin_version}"
99
id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlin_version}"
1010
id 'org.jetbrains.dokka' version "1.9.10"
11+
id 'com.github.johnrengelman.shadow' version '8.1.1'
1112
}
1213

1314
java.toolchain.languageVersion = JavaLanguageVersion.of(17)
@@ -22,24 +23,41 @@ version = "${minecraft_version}-${mod_version}"
2223
//////////////////
2324

2425
repositories {
26+
mavenCentral()
2527
maven {
2628
name 'Kotlin for Forge'
2729
url 'https://thedarkcolour.github.io/KotlinForForge/'
2830
content { includeGroup "thedarkcolour" }
2931
}
3032
}
3133

34+
configurations {
35+
shade
36+
implementation.extendsFrom shade
37+
}
38+
3239
dependencies {
3340
minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}"
3441

3542
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
3643

3744
implementation "thedarkcolour:kotlinforforge:${kotlinforforge_version}"
45+
46+
shade "com.googlecode.soundlibs:jlayer:1.0.1.4"
3847
}
3948

4049
minecraft {
4150
mappings channel: mappings_channel, version: mappings_version
4251
runs {
52+
configureEach {
53+
lazyToken('minecraft_classpath') {
54+
configurations.shade
55+
.copyRecursive()
56+
.resolve()
57+
.collect { it.absolutePath }
58+
.join(File.pathSeparator)
59+
}
60+
}
4361
client {
4462
property 'forge.logging.markers', 'REGISTRIES'
4563
property 'forge.logging.console.level', 'debug'
@@ -89,21 +107,37 @@ processResources {
89107
}
90108
}
91109

110+
shadowJar {
111+
archiveClassifier = ''
112+
configurations = [project.configurations.shade]
113+
114+
relocate 'javazoom', 'com.hemogoblins.betterrecords.shadow.javazoom'
115+
116+
finalizedBy 'reobfShadowJar'
117+
}
118+
119+
reobf {
120+
shadowJar {}
121+
}
122+
123+
tasks.named('jar') {
124+
archiveClassifier = 'slim'
125+
}
126+
127+
assemble.dependsOn shadowJar
128+
92129
task deobfJar(type: Jar) {
93130
from sourceSets.main.output
94-
//noinspection GroovyAccessibility
95131
archiveClassifier = 'deobf'
96132
}
97133

98134
task sourcesJar(type: Jar) {
99135
from sourceSets.main.allJava
100-
//noinspection GroovyAccessibility
101136
archiveClassifier = 'sources'
102137
}
103138

104139
task javadocJar(type: Jar, dependsOn: javadoc) {
105140
from javadoc.destinationDir
106-
//noinspection GroovyAccessibility
107141
archiveClassifier = 'javadoc'
108142
}
109143

src/main/java/com/hemogoblins/betterrecords/mixin/MixinSoundEngine.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import com.hemogoblins.betterrecords.BRConfig;
44
import com.hemogoblins.betterrecords.BetterRecords;
5+
import com.hemogoblins.betterrecords.api.client.sound.AudioStreamRegistry;
56
import com.hemogoblins.betterrecords.api.client.sound.FileSoundInstance;
7+
import com.hemogoblins.betterrecords.api.client.sound.RecordAudioStream;
68
import com.hemogoblins.betterrecords.client.sound.ProgressInputStream;
79
import com.hemogoblins.betterrecords.client.sound.RecordSoundManager;
810
import com.mojang.blaze3d.audio.Channel;
911
import com.mojang.blaze3d.audio.Library;
10-
import com.mojang.blaze3d.audio.OggAudioStream;
1112
import com.mojang.blaze3d.audio.SoundBuffer;
1213
import net.minecraft.Util;
1314
import net.minecraft.client.Minecraft;
@@ -110,7 +111,9 @@ public void onPlay(SoundInstance sound, CallbackInfo ci) {
110111
});
111112
}
112113

113-
/** Reads and decodes an OGG file into a static buffer. Runs off the main thread. */
114+
/**
115+
* Reads and decodes a cached audio file into a static buffer.
116+
*/
114117
@Unique
115118
private SoundBuffer betterRecords$decode(FileSoundInstance fileSound, File soundFile) {
116119
if (!soundFile.exists()) {
@@ -119,8 +122,8 @@ public void onPlay(SoundInstance sound, CallbackInfo ci) {
119122

120123
try (FileInputStream stream = new FileInputStream(soundFile);
121124
ProgressInputStream progressStream = new ProgressInputStream(stream, soundFile.length(), fileSound);
122-
OggAudioStream oggStream = new OggAudioStream(progressStream)) {
123-
SoundBuffer soundBuffer = new SoundBuffer(oggStream.readAll(), oggStream.getFormat());
125+
RecordAudioStream audioStream = AudioStreamRegistry.INSTANCE.open(progressStream)) {
126+
SoundBuffer soundBuffer = new SoundBuffer(audioStream.readAll(), audioStream.getFormat());
124127
fileSound.setLoadProgress(1.0F);
125128
return soundBuffer;
126129
} catch (Exception e) {

src/main/kotlin/com/hemogoblins/betterrecords/BetterRecords.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.hemogoblins.betterrecords
22

33
import com.hemogoblins.betterrecords.api.client.MusicCache
4+
import com.hemogoblins.betterrecords.api.client.sound.AudioStreamRegistry
45
import com.hemogoblins.betterrecords.block.ModBlocks
56
import com.hemogoblins.betterrecords.block.renderer.ModRenderers
67
import com.hemogoblins.betterrecords.capability.ModCapabilities
78
import com.hemogoblins.betterrecords.client.cache.FilesystemCache
89
import com.hemogoblins.betterrecords.client.gui.ModOverlays
10+
import com.hemogoblins.betterrecords.client.sound.format.Mp3AudioStreamProvider
11+
import com.hemogoblins.betterrecords.client.sound.format.OggAudioStreamProvider
912
import com.hemogoblins.betterrecords.client.screen.ModScreens
1013
import com.hemogoblins.betterrecords.item.ModItems
1114
import com.hemogoblins.betterrecords.menu.ModMenuTypes
@@ -68,6 +71,8 @@ object BetterRecords {
6871
* Client side setup events
6972
*/
7073
private fun onClientSetup(event: FMLClientSetupEvent) {
74+
AudioStreamRegistry.register(OggAudioStreamProvider)
75+
AudioStreamRegistry.register(Mp3AudioStreamProvider)
7176
}
7277

7378
/**
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.hemogoblins.betterrecords.api.client.sound
2+
3+
import java.io.IOException
4+
import java.io.InputStream
5+
6+
interface AudioStreamProvider {
7+
8+
/** Short, lowercase identifier for the format, e.g. `"ogg"` or `"mp3"`. */
9+
val id: String
10+
11+
/**
12+
* Whether this provider can decode a stream that begins with [header].
13+
*
14+
* [header] holds the first few bytes of the stream (see [AudioStreamRegistry.HEADER_LENGTH]);
15+
* Implementations should sniff magic bytes to determine format.
16+
*/
17+
fun matches(header: ByteArray): Boolean
18+
19+
/**
20+
* Wraps [input] in a [RecordAudioStream] that decodes it to PCM.
21+
*/
22+
@Throws(IOException::class)
23+
fun create(input: InputStream): RecordAudioStream
24+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.hemogoblins.betterrecords.api.client.sound
2+
3+
import com.hemogoblins.betterrecords.BetterRecords
4+
import java.io.IOException
5+
import java.io.InputStream
6+
import java.io.PushbackInputStream
7+
import java.util.concurrent.CopyOnWriteArrayList
8+
9+
/**
10+
* Registry of [AudioStreamProvider]s, used to pick the right decoder for a cached
11+
* audio file at playback time.
12+
*
13+
* Detection is done by sniffing the first [HEADER_LENGTH] bytes of the stream, so
14+
* the format does not have to be known ahead of time
15+
*/
16+
object AudioStreamRegistry {
17+
18+
/** Number of leading bytes handed to [AudioStreamProvider.matches] for sniffing. */
19+
const val HEADER_LENGTH = 4
20+
21+
private val providers = CopyOnWriteArrayList<AudioStreamProvider>()
22+
23+
/** Registers [provider]. Providers are checked in registration order. */
24+
fun register(provider: AudioStreamProvider) {
25+
providers.add(provider)
26+
BetterRecords.logger.info("Registered audio stream provider: {}", provider.id)
27+
}
28+
29+
/** All registered providers. */
30+
fun providers(): List<AudioStreamProvider> = providers.toList()
31+
32+
/**
33+
* Sniffs [input] and wraps it in a [RecordAudioStream] using the first provider that [AudioStreamProvider.matches] its header.
34+
*
35+
* @throws IOException if no registered provider supports the stream.
36+
*/
37+
@Throws(IOException::class)
38+
fun open(input: InputStream): RecordAudioStream {
39+
val pushback = PushbackInputStream(input, HEADER_LENGTH)
40+
41+
val header = ByteArray(HEADER_LENGTH)
42+
var read = 0
43+
while (read < HEADER_LENGTH) {
44+
val r = pushback.read(header, read, HEADER_LENGTH - read)
45+
if (r < 0) break
46+
read += r
47+
}
48+
if (read > 0) pushback.unread(header, 0, read)
49+
50+
val sniffed = if (read == HEADER_LENGTH) header else header.copyOf(read)
51+
val provider = providers.firstOrNull { it.matches(sniffed) }
52+
?: throw IOException(
53+
"No audio stream provider for header [${sniffed.joinToString(" ") { "%02X".format(it) }}]"
54+
)
55+
56+
BetterRecords.logger.debug("Decoding record audio using '{}' provider", provider.id)
57+
return provider.create(pushback)
58+
}
59+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.hemogoblins.betterrecords.api.client.sound
2+
3+
import java.io.Closeable
4+
import java.io.IOException
5+
import java.nio.ByteBuffer
6+
import javax.sound.sampled.AudioFormat
7+
8+
/**
9+
* A source of decoded, signed 16-bit PCM audio ready for OpenAL.
10+
*/
11+
interface RecordAudioStream : Closeable {
12+
13+
/**
14+
* The format of the PCM returned by [readAll]. Channels must be mono or stereo
15+
* and the sample size 8 or 16 bit, those are the only layouts OpenAL accepts.
16+
*/
17+
val format: AudioFormat
18+
19+
/**
20+
* Fully decodes the stream into a single direct [ByteBuffer] of PCM
21+
*/
22+
@Throws(IOException::class)
23+
fun readAll(): ByteBuffer
24+
}

src/main/kotlin/com/hemogoblins/betterrecords/client/screen/RecordEtcherScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class RecordEtcherScreen(
2727
val etchButton = Button.builder(Component.translatable("menu.${BetterRecords.ID}.record_etcher.etch")) {
2828
try {
2929
// TODO URL From text field
30-
val url = "https://betterrecords-files.s3.amazonaws.com/fastcar.ogg"
30+
val url = "https://betterrecords-files.s3.amazonaws.com/fastcar.mp3"
3131

3232
val cacheEntry = BetterRecords.cache.get(url ) { progressPercent ->
3333
// TODO: Progress Bar
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.hemogoblins.betterrecords.client.sound.format
2+
3+
import com.hemogoblins.betterrecords.api.client.sound.AudioStreamProvider
4+
import com.hemogoblins.betterrecords.api.client.sound.RecordAudioStream
5+
import javazoom.jl.decoder.Bitstream
6+
import javazoom.jl.decoder.BitstreamException
7+
import javazoom.jl.decoder.Decoder
8+
import javazoom.jl.decoder.DecoderException
9+
import javazoom.jl.decoder.Header
10+
import javazoom.jl.decoder.SampleBuffer
11+
import org.lwjgl.BufferUtils
12+
import java.io.IOException
13+
import java.io.InputStream
14+
import java.nio.ByteBuffer
15+
import javax.sound.sampled.AudioFormat
16+
17+
/**
18+
* MP3 support, backed by the JLayer decoder.
19+
*
20+
* JLayer decodes MP3 frames into interleaved signed 16-bit PCM, which is exactly what OpenAL wants.
21+
*/
22+
object Mp3AudioStreamProvider : AudioStreamProvider {
23+
24+
override val id = "mp3"
25+
26+
override fun matches(header: ByteArray): Boolean {
27+
// ID3v2-tagged mp3 files start with the literal "ID3".
28+
if (header.size >= 3 &&
29+
header[0] == 'I'.code.toByte() &&
30+
header[1] == 'D'.code.toByte() &&
31+
header[2] == '3'.code.toByte()
32+
) {
33+
return true
34+
}
35+
// Otherwise the file starts straight with an MPEG audio frame: 11 sync bits set.
36+
return header.size >= 2 &&
37+
header[0] == 0xFF.toByte() &&
38+
(header[1].toInt() and 0xE0) == 0xE0
39+
}
40+
41+
override fun create(input: InputStream): RecordAudioStream = Mp3AudioStream(input)
42+
43+
private class Mp3AudioStream(input: InputStream) : RecordAudioStream {
44+
45+
private val bitstream = Bitstream(input)
46+
private val decoder = Decoder()
47+
private var decodedFormat: AudioFormat? = null
48+
49+
override val format: AudioFormat
50+
get() = decodedFormat
51+
?: throw IllegalStateException("readAll() must be called before the format is known")
52+
53+
@Throws(IOException::class)
54+
override fun readAll(): ByteBuffer {
55+
val chunks = ArrayList<ShortArray>()
56+
var totalShorts = 0
57+
var sampleRate = -1
58+
var channels = -1
59+
60+
try {
61+
var header: Header? = bitstream.readFrame()
62+
while (header != null) {
63+
val output = decoder.decodeFrame(header, bitstream) as SampleBuffer
64+
if (sampleRate < 0) {
65+
sampleRate = output.sampleFrequency
66+
channels = output.channelCount
67+
}
68+
69+
val length = output.bufferLength
70+
chunks.add(output.buffer.copyOf(length))
71+
totalShorts += length
72+
73+
bitstream.closeFrame()
74+
header = bitstream.readFrame()
75+
}
76+
} catch (e: BitstreamException) {
77+
throw IOException("Failed to read MP3 stream", e)
78+
} catch (e: DecoderException) {
79+
throw IOException("Failed to decode MP3 stream", e)
80+
}
81+
82+
if (sampleRate < 0 || channels < 1) {
83+
throw IOException("MP3 stream contained no decodable audio frames")
84+
}
85+
86+
// Signed 16-bit, little-endian (native order) PCM — the layout OpenAL expects.
87+
decodedFormat = AudioFormat(sampleRate.toFloat(), 16, channels, true, false)
88+
89+
val buffer = BufferUtils.createByteBuffer(totalShorts * 2)
90+
for (chunk in chunks) {
91+
for (sample in chunk) {
92+
buffer.putShort(sample)
93+
}
94+
}
95+
buffer.flip()
96+
return buffer
97+
}
98+
99+
override fun close() {
100+
try {
101+
bitstream.close()
102+
} catch (e: BitstreamException) {
103+
throw IOException("Failed to close MP3 stream", e)
104+
}
105+
}
106+
}
107+
}

0 commit comments

Comments
 (0)