diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c08f14a..70ec55d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -46,10 +46,10 @@ jobs: # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '25' distribution: 'temurin' cache: maven server-id: github # Value of the distributionManagement/repository/id field of the pom.xml diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1c23bb0..28b1252 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -22,10 +22,10 @@ jobs: if: ${{ contains(github.event.head_commit.message, 'bump version') }} run: grep "" pom.xml | head -1 | grep -v SNAPSHOT - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '25' distribution: 'temurin' cache: maven server-id: github # Value of the distributionManagement/repository/id field of the pom.xml diff --git a/README.md b/README.md index 4d62571..5c751fc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Release](https://jitpack.io/v/umjammer/mp3spi.svg)](https://jitpack.io/#umjammer/mp3spi) [![Java CI](https://github.com/umjammer/mp3spi/actions/workflows/maven.yml/badge.svg)](https://github.com/umjammer/mp3spi/actions/workflows/maven.yml) [![CodeQL](https://github.com/umjammer/mp3spi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/umjammer/mp3spi/actions/workflows/codeql-analysis.yml) -![Java](https://img.shields.io/badge/Java-17-b07219) +![Java](https://img.shields.io/badge/Java-25-b07219) [![Parent](https://img.shields.io/badge/Parent-vavi--sound--sandbox-pink)](https://github.com/umjammer/vavi-sound-sandbox) # MP3SPI @@ -48,6 +48,7 @@ Both are in pure Java. * out source tag parser (use like vavi-util-tag) * ~~out source version~~ * [jsidplay2:jump3r](https://github.com/umjammer/JSIDPlay2/tree/vavi/jump3r) is the origin of java-lame (replace?) + * wav file reader ---- diff --git a/jitpack.yml b/jitpack.yml index efde7bf..aaa5890 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,2 +1,2 @@ jdk: - - openjdk17 + - openjdk25 diff --git a/pom.xml b/pom.xml index 1dda01f..cc494c1 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ net.javazoom mp3spi - 1.9.18 + 1.9.19 com.github.umjammer.tritonus @@ -53,8 +53,8 @@ maven-compiler-plugin 3.12.1 - 17 - 17 + 25 + 25 --add-exports java.desktop/com.sun.media.sound=ALL-UNNAMED @@ -89,7 +89,7 @@ org.junit junit-bom - 6.0.2 + 6.0.3 pom import @@ -100,7 +100,7 @@ com.github.umjammer vavi-sound - 1.0.22 + 1.0.27 diff --git a/src/main/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProvider.java b/src/main/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProvider.java index 9d5380f..74cddb0 100644 --- a/src/main/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProvider.java +++ b/src/main/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProvider.java @@ -25,10 +25,11 @@ import java.lang.System.Logger; import java.lang.System.Logger.Level; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioInputStream; -import javax.sound.sampled.AudioSystem; import javazoom.spi.mpeg.sampled.file.MpegEncoding; import org.tritonus.share.sampled.Encodings; @@ -45,18 +46,33 @@ public class MpegFormatConversionProvider extends TEncodingFormatConversionProvi private static final Logger logger = getLogger("org.tritonus.TraceAudioConverter"); - private static final AudioFormat.Encoding MP3 = Encodings.getEncoding("MP3"); private static final AudioFormat.Encoding PCM_SIGNED = Encodings.getEncoding("PCM_SIGNED"); - private static final AudioFormat[] INPUT_FORMATS = { - // mono - new AudioFormat(MP3, NOT_SPECIFIED, NOT_SPECIFIED, 1, NOT_SPECIFIED, NOT_SPECIFIED, false), - new AudioFormat(MP3, NOT_SPECIFIED, NOT_SPECIFIED, 1, NOT_SPECIFIED, NOT_SPECIFIED, true), - // stereo - new AudioFormat(MP3, NOT_SPECIFIED, NOT_SPECIFIED, 2, NOT_SPECIFIED, NOT_SPECIFIED, false), - new AudioFormat(MP3, NOT_SPECIFIED, NOT_SPECIFIED, 2, NOT_SPECIFIED, NOT_SPECIFIED, true), + /** + * The encodings {@link javazoom.spi.mpeg.sampled.file.MpegAudioFileReader} puts into the + * formats it returns. Those are what a source format actually holds, an encoding simply + * named "MP3" is never one of them. + */ + private static final AudioFormat.Encoding[] MPEG_ENCODINGS = { + MpegEncoding.MPEG1L1, MpegEncoding.MPEG1L2, MpegEncoding.MPEG1L3, + MpegEncoding.MPEG2L1, MpegEncoding.MPEG2L2, MpegEncoding.MPEG2L3, + MpegEncoding.MPEG2DOT5L1, MpegEncoding.MPEG2DOT5L2, MpegEncoding.MPEG2DOT5L3, }; + private static final AudioFormat[] INPUT_FORMATS = inputFormats(); + + private static AudioFormat[] inputFormats() { + List formats = new ArrayList<>(); + for (AudioFormat.Encoding encoding : MPEG_ENCODINGS) { + for (int channels : new int[] {1, 2}) { + for (boolean bigEndian : new boolean[] {false, true}) { + formats.add(new AudioFormat(encoding, NOT_SPECIFIED, NOT_SPECIFIED, channels, NOT_SPECIFIED, NOT_SPECIFIED, bigEndian)); + } + } + } + return formats.toArray(AudioFormat[]::new); + } + private static final AudioFormat[] OUTPUT_FORMATS = { // mono, 16 bit signed new AudioFormat(PCM_SIGNED, NOT_SPECIFIED, 16, 1, 2, NOT_SPECIFIED, false), @@ -74,37 +90,84 @@ public MpegFormatConversionProvider() { logger.log(Level.TRACE, ">MpegFormatConversionProvider()"); } - @Override - public AudioInputStream getAudioInputStream(AudioFormat targetFormat, AudioInputStream audioInputStream) { - logger.log(Level.TRACE, ">MpegFormatConversionProvider.getAudioInputStream(AudioFormat targetFormat, AudioInputStream audioInputStream):"); - return new DecodedMpegAudioInputStream(targetFormat, audioInputStream); + /** + * Whether the given format is an mpeg stream this provider can decode, i.e. one that + * carries everything the decoder needs to size its output. + */ + private static boolean isDecodable(AudioFormat sourceFormat) { + return sourceFormat.getEncoding() instanceof MpegEncoding + && (sourceFormat.getFrameRate() != NOT_SPECIFIED || sourceFormat.getFrameSize() != NOT_SPECIFIED) + && sourceFormat.getChannels() != NOT_SPECIFIED + && sourceFormat.getSampleRate() != NOT_SPECIFIED; + } + + /** + * The one pcm format the decoder produces out of the given mpeg stream. It never changes + * the sample rate nor the channel count, and always writes 16 bit signed samples; only + * the endianness is up to the caller. + */ + private static AudioFormat decodedFormat(AudioFormat sourceFormat, boolean bigEndian) { + return new AudioFormat(PCM_SIGNED, + sourceFormat.getSampleRate(), + 16, + sourceFormat.getChannels(), + sourceFormat.getChannels() * 2, + sourceFormat.getSampleRate(), + bigEndian); } /** - * Add conversion support for any MpegEncoding source with FrameRate or FrameSize not empty. - * - * @param targetFormat - * @param sourceFormat - * @return + * The formats of the super class are matched against the source with + * {@code AudioFormats.matches()}, which cannot express "same sample rate and same channel + * count as the source". They are therefore built from the source format here. */ @Override - public boolean isConversionSupported(AudioFormat targetFormat, AudioFormat sourceFormat) { - logger.log(Level.TRACE, ">MpegFormatConversionProvider.isConversionSupported(AudioFormat targetFormat, AudioFormat sourceFormat):"); - logger.log(Level.TRACE, "checking if conversion possible"); - logger.log(Level.TRACE, "from: " + sourceFormat); - logger.log(Level.TRACE, "to: " + targetFormat); - - boolean conversion = super.isConversionSupported(targetFormat, sourceFormat); - if (!conversion) { - AudioFormat.Encoding enc = sourceFormat.getEncoding(); - if (enc instanceof MpegEncoding) { - if ((sourceFormat.getFrameRate() != AudioSystem.NOT_SPECIFIED) || (sourceFormat.getFrameSize() != AudioSystem.NOT_SPECIFIED)) { - if (sourceFormat.getChannels() == targetFormat.getChannels()) { - conversion = true; - } - } + public AudioFormat[] getTargetFormats(AudioFormat.Encoding targetEncoding, AudioFormat sourceFormat) { + logger.log(Level.TRACE, ">MpegFormatConversionProvider.getTargetFormats(AudioFormat.Encoding targetEncoding, AudioFormat sourceFormat):"); + if (isDecodable(sourceFormat)) { + if (PCM_SIGNED.equals(targetEncoding)) { + return new AudioFormat[] { + decodedFormat(sourceFormat, false), + decodedFormat(sourceFormat, true), + }; } + return new AudioFormat[0]; + } + return super.getTargetFormats(targetEncoding, sourceFormat); + } + + @Override + public AudioFormat.Encoding[] getTargetEncodings(AudioFormat sourceFormat) { + logger.log(Level.TRACE, ">MpegFormatConversionProvider.getTargetEncodings(AudioFormat sourceFormat):"); + if (isDecodable(sourceFormat)) { + return new AudioFormat.Encoding[] {PCM_SIGNED}; + } + return super.getTargetEncodings(sourceFormat); + } + + @Override + public AudioInputStream getAudioInputStream(AudioFormat.Encoding targetEncoding, AudioInputStream audioInputStream) { + logger.log(Level.TRACE, ">MpegFormatConversionProvider.getAudioInputStream(AudioFormat.Encoding targetEncoding, AudioInputStream audioInputStream):"); + AudioFormat sourceFormat = audioInputStream.getFormat(); + if (isDecodable(sourceFormat) && PCM_SIGNED.equals(targetEncoding)) { + // the super class would ask for a target format with every field NOT_SPECIFIED, + // which the decoder cannot size its output buffer from + return getAudioInputStream(decodedFormat(sourceFormat, sourceFormat.isBigEndian()), audioInputStream); } - return conversion; + return super.getAudioInputStream(targetEncoding, audioInputStream); + } + + @Override + public AudioInputStream getAudioInputStream(AudioFormat targetFormat, AudioInputStream audioInputStream) { + logger.log(Level.TRACE, ">MpegFormatConversionProvider.getAudioInputStream(AudioFormat targetFormat, AudioInputStream audioInputStream):"); + AudioFormat sourceFormat = audioInputStream.getFormat(); + if (isDecodable(sourceFormat)) { + if (!isConversionSupported(targetFormat, sourceFormat)) { + throw new IllegalArgumentException("unable to convert " + sourceFormat + " to " + targetFormat); + } + // the requested format may leave fields unspecified, the decoder needs them all + return new DecodedMpegAudioInputStream(decodedFormat(sourceFormat, targetFormat.isBigEndian()), audioInputStream); + } + return new DecodedMpegAudioInputStream(targetFormat, audioInputStream); } } diff --git a/src/main/java/javazoom/spi/mpeg/sampled/file/MpegAudioFileReader.java b/src/main/java/javazoom/spi/mpeg/sampled/file/MpegAudioFileReader.java index 9bce594..4a83fe8 100644 --- a/src/main/java/javazoom/spi/mpeg/sampled/file/MpegAudioFileReader.java +++ b/src/main/java/javazoom/spi/mpeg/sampled/file/MpegAudioFileReader.java @@ -151,7 +151,7 @@ public AudioFileFormat getAudioFileFormat(URL url) throws UnsupportedAudioFileEx // Tell shoucast server (if any) that SPI support shoutcast stream. conn.setRequestProperty("Icy-Metadata", "1"); InputStream inputStream = conn.getInputStream(); - AudioFileFormat audioFileFormat = null; + AudioFileFormat audioFileFormat; try { audioFileFormat = getAudioFileFormat(inputStream, lFileLengthInBytes); } finally { @@ -166,9 +166,9 @@ public AudioFileFormat getAudioFileFormat(URL url) throws UnsupportedAudioFileEx * * @param inputStream it's user's responsibility to prepare enough read buffer for mp3 tag analysis like *
-     *                    ... = getAudioFileFormat(new BufferedInputStream(your_input_stream, INITIAL_READ_LENGTH)) ...
+     *                    ... = getAudioFileFormat(new BufferedInputStream(your_input_stream, max_file_length - 8  - 1)) ...
      *                    
- * and max buffer size is defined in {@link #INITIAL_READ_LENGTH}. + * and max buffer size is defined by the system property {@code mp3spi.bufferSize}. * @see #INITIAL_READ_LENGTH */ @Override @@ -196,7 +196,7 @@ public AudioFileFormat getAudioFileFormat(InputStream inputStream, long mediaLen if (!weak) { if (typeOfFormat != 0x55) throw new UnsupportedAudioFileException("WAV (" + typeOfFormat + ") stream found"); } - pis.skip(22); // TODO sloppy + pis.skipNBytes(22); // TODO sloppy } else { if (!weak) throw new UnsupportedAudioFileException("unsupported WAV stream found"); } diff --git a/src/test/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProviderTest.java b/src/test/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProviderTest.java new file mode 100644 index 0000000..209b3dc --- /dev/null +++ b/src/test/java/javazoom/spi/mpeg/sampled/convert/MpegFormatConversionProviderTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 by Naohide Sano, All rights reserved. + * + * Programmed by Naohide Sano + */ + +package javazoom.spi.mpeg.sampled.convert; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.util.List; +import java.util.logging.Logger; +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioFormat.Encoding; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; + +import javazoom.spi.mpeg.sampled.file.MpegEncoding; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** + * What this provider says it can do must be what it can do. It used to say yes to any target + * format with the same channel count as the source, whatever its encoding, and at the same + * time no to every target encoding, which made + * {@code AudioSystem.getAudioInputStream(Encoding.PCM_SIGNED, mp3)} fail. + * + * @author Naohide Sano (nsano) + * @version 0.00 260726 nsano initial version
+ */ +class MpegFormatConversionProviderTest { + + private static final Logger logger = Logger.getLogger(MpegFormatConversionProviderTest.class.getName()); + + /** test.mp3 is stereo, mono.mp3 is monaural */ + static AudioInputStream stream(String name) throws Exception { + InputStream in = MpegFormatConversionProviderTest.class.getResourceAsStream(name); + return AudioSystem.getAudioInputStream(new BufferedInputStream(in)); + } + + static AudioFormat pcm(float sampleRate, int channels) { + return new AudioFormat(Encoding.PCM_SIGNED, sampleRate, 16, channels, channels * 2, sampleRate, false); + } + + @Test + @DisplayName("the encodings of the source formats are the ones the reader produces") + void test1() throws Exception { + MpegFormatConversionProvider provider = new MpegFormatConversionProvider(); + assertTrue(provider.isSourceEncodingSupported(MpegEncoding.MPEG1L3)); + assertTrue(provider.isSourceEncodingSupported(MpegEncoding.MPEG2DOT5L3)); + assertTrue(provider.isTargetEncodingSupported(Encoding.PCM_SIGNED)); + } + + @Test + @DisplayName("says yes to the target encoding it can decode to") + void test2() throws Exception { + AudioFormat sourceFormat = stream("/test.mp3").getFormat(); +logger.info("In Format: " + sourceFormat); + + assertTrue(AudioSystem.isConversionSupported(Encoding.PCM_SIGNED, sourceFormat)); + assertTrue(List.of(AudioSystem.getTargetEncodings(sourceFormat)).contains(Encoding.PCM_SIGNED)); + + try (AudioInputStream out = AudioSystem.getAudioInputStream(Encoding.PCM_SIGNED, stream("/test.mp3"))) { +logger.info("Out Format: " + out.getFormat()); + assertEquals(Encoding.PCM_SIGNED, out.getFormat().getEncoding()); + assertEquals(sourceFormat.getSampleRate(), out.getFormat().getSampleRate()); + assertEquals(sourceFormat.getChannels(), out.getFormat().getChannels()); + assertEquals(16, out.getFormat().getSampleSizeInBits()); + assertTrue(out.readNBytes(0x10000).length > 0); + } + } + + @Test + @DisplayName("says no to a target encoding it knows nothing about") + void test3() throws Exception { + AudioFormat sourceFormat = stream("/test.mp3").getFormat(); + AudioFormat targetFormat = new AudioFormat(new Encoding("NOWHERE"), + sourceFormat.getSampleRate(), 16, sourceFormat.getChannels(), sourceFormat.getChannels() * 2, + sourceFormat.getSampleRate(), false); + + assertFalse(AudioSystem.isConversionSupported(targetFormat, sourceFormat)); + assertFalse(AudioSystem.isConversionSupported(new Encoding("NOWHERE"), sourceFormat)); + assertThrows(IllegalArgumentException.class, () -> AudioSystem.getAudioInputStream(targetFormat, stream("/test.mp3"))); + } + + @Test + @DisplayName("the advertised target formats are the ones it really produces") + void test4() throws Exception { + AudioFormat sourceFormat = stream("/test.mp3").getFormat(); + + AudioFormat[] targetFormats = AudioSystem.getTargetFormats(Encoding.PCM_SIGNED, sourceFormat); + assertTrue(targetFormats.length > 0); + for (AudioFormat targetFormat : targetFormats) { +logger.info("Target Format: " + targetFormat); + assertEquals(sourceFormat.getSampleRate(), targetFormat.getSampleRate()); + assertEquals(sourceFormat.getChannels(), targetFormat.getChannels()); + assertEquals(16, targetFormat.getSampleSizeInBits()); + assertTrue(AudioSystem.isConversionSupported(targetFormat, sourceFormat)); + try (AudioInputStream out = AudioSystem.getAudioInputStream(targetFormat, stream("/test.mp3"))) { + assertTrue(targetFormat.matches(out.getFormat())); + assertTrue(out.readNBytes(0x1000).length > 0); + } + } + } + + @Test + @DisplayName("still cannot change the channel count on its own") + void test5() throws Exception { + AudioFormat stereo = stream("/test.mp3").getFormat(); + AudioFormat mono = stream("/mono.mp3").getFormat(); + + MpegFormatConversionProvider provider = new MpegFormatConversionProvider(); + assertFalse(provider.isConversionSupported(pcm(stereo.getSampleRate(), 1), stereo)); + assertFalse(provider.isConversionSupported(pcm(mono.getSampleRate(), 2), mono)); + } + + @Test + @DisplayName("does not resample either") + void test6() throws Exception { + AudioFormat sourceFormat = stream("/test.mp3").getFormat(); + + MpegFormatConversionProvider provider = new MpegFormatConversionProvider(); + assertFalse(provider.isConversionSupported(pcm(22050, sourceFormat.getChannels()), sourceFormat)); + assertTrue(provider.isConversionSupported(pcm(sourceFormat.getSampleRate(), sourceFormat.getChannels()), sourceFormat)); + } +} diff --git a/src/test/java/javazoom/spi/mpeg/sampled/file/MonoTest.java b/src/test/java/javazoom/spi/mpeg/sampled/file/MonoTest.java index 7046cfa..6583dbc 100644 --- a/src/test/java/javazoom/spi/mpeg/sampled/file/MonoTest.java +++ b/src/test/java/javazoom/spi/mpeg/sampled/file/MonoTest.java @@ -74,7 +74,7 @@ void test1() throws Exception { inFormat.getSampleRate(), 16, inFormat.getChannels(), - inFormat.getChannels(), + inFormat.getChannels() * 2, // 16 bit, so 2 bytes per channel inFormat.getSampleRate(), false); logger.info("Out Format: " + outFormat);