Skip to content

Commit 53f2f8b

Browse files
authored
Merge pull request #25 from hendriks73/support_emojis
Support emojis. Added CLAUDE.md.
2 parents cd8fbd7 + ae9956f commit 53f2f8b

8 files changed

Lines changed: 314 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
---
6+
7+
## Task Execution Guidelines
8+
9+
Follow these practices for all non-trivial work in this repository.
10+
11+
### Plan before coding
12+
13+
Before writing any code, think through the approach. For any task beyond a trivial fix:
14+
1. Draft a plan — identify the files to change, the interfaces to add/modify, and the test cases needed.
15+
2. Review the plan against the constraints in this file.
16+
3. Only start editing once the plan is coherent. A bad plan caught early is far cheaper than a bad implementation caught late.
17+
18+
### Write tests first
19+
20+
When adding new functionality, write the failing test before writing the implementation. A failing test creates an immediate, objective feedback loop. This dramatically improves reliability — run the test after every meaningful change to stay oriented.
21+
22+
### Break work into small increments
23+
24+
Prefer many small, verifiable steps over one large change. Each increment should:
25+
- Leave the codebase in a working state (tests pass, linter clean).
26+
- Be independently reviewable.
27+
28+
If a task feels too large to hold in one session, decompose it further.
29+
30+
---
31+
32+
## Project Overview
33+
34+
FFSampledSP is a Java/JNI library that implements `javax.sound.sampled` service provider interfaces (SPIs) backed by FFmpeg. It decodes audio files/streams to signed linear PCM. Licensed under LGPL 2.1.
35+
36+
## Build Commands
37+
38+
The build requires Maven 3.6+, a JDK, and Doxygen. Native compilation requires platform-specific toolchains. **A platform profile must be activated** — native modules are not built by default.
39+
40+
### macOS (native for current platform)
41+
```bash
42+
# Build and test for aarch64 (Apple Silicon)
43+
mvn --activate-profiles ffsampledsp-aarch64-macos install
44+
45+
# Build and test for x86_64
46+
mvn --activate-profiles ffsampledsp-x86_64-macos install
47+
```
48+
49+
### Linux
50+
```bash
51+
mvn --activate-profiles ffsampledsp-x86_64-linux install
52+
# For aarch64 cross-compile (requires aarch64-linux-gnu-gcc, tests are skipped):
53+
mvn --activate-profiles ffsampledsp-aarch64-linux install
54+
```
55+
56+
### Windows (requires MSYS2 with MinGW toolchain)
57+
```bash
58+
mvn --activate-profiles ffsampledsp-x86_64-win install # 64-bit
59+
mvn --activate-profiles ffsampledsp-i386-win install # 32-bit (tests skipped)
60+
```
61+
62+
### Java-only (no native compilation)
63+
```bash
64+
mvn install # builds ffsampledsp-java and ffsampledsp-complete only
65+
```
66+
67+
### Run tests
68+
Tests live in `ffsampledsp-complete/src/test/java/`. They require the native library to be built first (via a platform profile).
69+
70+
```bash
71+
# Run all tests (after native build)
72+
mvn --activate-profiles ffsampledsp-aarch64-macos test
73+
74+
# Run a single test class
75+
mvn --activate-profiles ffsampledsp-aarch64-macos test \
76+
-pl ffsampledsp-complete \
77+
-Dtest=TestFFAudioFileReader
78+
```
79+
80+
### Debug builds
81+
Pass `-Dcflags=-DDEBUG` to enable C-level debug output to stdout.
82+
83+
## Architecture
84+
85+
### Module Structure
86+
87+
- **`ffsampledsp-java/`** — Pure Java SPI implementations. The authoritative Java source; compiled standalone but also copied into `ffsampledsp-complete` at build time.
88+
- **`ffsampledsp-{arch}-{host}/`** — Per-platform native modules (`x86_64-macos`, `aarch64-macos`, `x86_64-linux`, `aarch64-linux`, `x86_64-win`, `i386-win`). Each packages a `.dylib`/`.so`/`.dll`. The C sources live only in `ffsampledsp-x86_64-macos/src/main/c/` — all other platform modules symlink or reference the same sources.
89+
- **`ffsampledsp-complete/`** — The distribution artifact. Copies Java sources from `ffsampledsp-java` and embeds the native library from whichever platform profile is active. This is the jar users depend on.
90+
91+
### Java/JNI Layer
92+
93+
The Java classes in `com.tagtraum.ffsampledsp` implement the `javax.sound.sampled.spi` interfaces:
94+
95+
- **`FFAudioFileReader`** — implements `AudioFileReader`. Opens URLs/files/streams via FFmpeg. Caches results (LRU, 20 entries). Has a `getAudioFileFormats()` extension returning multiple formats for multi-stream files (e.g. Stems). Calls into native via two `native` methods: `getAudioFileFormatsFromURL` and `getAudioFileFormatsFromBuffer`.
96+
- **`FFFormatConversionProvider`** — implements `AudioFormatConversionProvider`. Transcodes compressed streams to PCM.
97+
- **`FFNativePeerInputStream`** — abstract base for native-backed `InputStream`s. Holds a `long pointer` to the native C struct and a direct `ByteBuffer` (`nativeBuffer`) that the C side fills.
98+
- **`FFURLInputStream`** — decodes from a URL/file path.
99+
- **`FFStreamInputStream`** — decodes from a Java `InputStream` (reads into a buffer, probes format, then decodes).
100+
- **`FFCodecInputStream`** — handles format conversion (resampling/channel mapping) using `libswresample`.
101+
- **`FFAudioInputStream`** — wraps an `FFNativePeerInputStream`, implements seeking via `FFGlobalLock`.
102+
- **`FFGlobalLock`** — a single `ReentrantLock` (`LOCK`) used to serialize FFmpeg calls that are not thread-safe (`avcodec_open2`, etc.).
103+
- **`FFNativeLibraryLoader`** — extracts the embedded native library to `java.io.tmpdir` and loads it. Naming convention: `ffsampledsp-{arch}-{host}.{ext}` (e.g. `ffsampledsp-aarch64-macos.dylib`).
104+
105+
Java language/compiler is specified in the main pom.xml file.
106+
107+
### C Native Layer (`ffsampledsp-x86_64-macos/src/main/c/`)
108+
109+
- **`FFUtils.c` / `FFUtils.h`** — shared helpers: JNI field/method ID caching, buffer management, FFmpeg context lifecycle, DRM detection (`CODEC_TAG_DRMS`). Minimum probe score of 5 prevents misdetecting files that other `javax.sound.sampled` providers should handle.
110+
- **`FFAudioFileReader.c`** — native implementation of the two `FFAudioFileReader` native methods. Probes format, fills Java `FFAudioFileFormat` / `FFAudioFormat` objects.
111+
- **`FFURLInputStream.c`** — opens an `AVFormatContext` from a URL, decodes packets into the Java `nativeBuffer`.
112+
- **`FFStreamInputStream.c`** — uses FFmpeg's custom I/O (`AVIOContext` with read callbacks) to pull data from a Java `InputStream`.
113+
- **`FFCodecInputStream.c`** — wraps `libswresample` for PCM conversion.
114+
115+
### Key Design Points
116+
117+
- The native library is embedded inside `ffsampledsp-complete.jar` and extracted to a temp file on first load. The extracted filename includes the version to allow side-by-side installs; SNAPSHOT builds are always re-extracted.
118+
- All calls touching FFmpeg's non-thread-safe API are wrapped in `FFGlobalLock.LOCK`.
119+
- Windows URLs require a special format for libav: `file:C:/path/file` (not `file:///C:/path/file`). UNC paths use `file://server/path`. This conversion happens in `FFAudioFileReader.urlToString()`.
120+
- The `fileToURL` method explicitly decodes then re-encodes file URIs to preserve `+` characters in paths (a known edge case).
121+
- JNI headers are auto-generated by `javac -h` during the `compile` phase into `target/native/include/`, then consumed by the platform-specific native module.

NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
- 0.9.54
2-
- Updated Maven project report skin (fixing the *Fork Me*-banner).
2+
- Updated Maven project report skin (fixing the *Fork Me*-banner).
3+
- Support emojis and %20 in paths.
34

45

56
- 0.9.53

ffsampledsp-complete/src/test/java/com/tagtraum/ffsampledsp/TestFFAudioFileReader.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import javax.sound.sampled.AudioFileFormat;
2828
import javax.sound.sampled.AudioFormat;
29+
import javax.sound.sampled.AudioInputStream;
2930
import javax.sound.sampled.UnsupportedAudioFileException;
3031
import java.io.*;
3132
import java.net.MalformedURLException;
@@ -326,6 +327,110 @@ public void testGetAudioFileFormatURL() throws IOException, UnsupportedAudioFile
326327
}
327328
}
328329

330+
@Test
331+
public void testGetAudioFileFormatURLWithEmojis() throws IOException, UnsupportedAudioFileException {
332+
// first copy the file from resources to actual location in temp
333+
final String filename = "test.ogg";
334+
final File file = File.createTempFile("testGetAudioFileFormatURLWithEmojis🔥", filename);
335+
extractFile(filename, file);
336+
try {
337+
final AudioFileFormat fileFormat = new FFAudioFileReader().getAudioFileFormat(file.toURI().toURL());
338+
System.out.println(fileFormat);
339+
340+
assertEquals("ogg", fileFormat.getType().getExtension());
341+
assertEquals(file.length(), fileFormat.getByteLength());
342+
assertEquals(NOT_SPECIFIED, fileFormat.getFrameLength());
343+
344+
final AudioFormat format = fileFormat.getFormat();
345+
assertEquals(NOT_SPECIFIED, format.getFrameSize());
346+
assertEquals(2, format.getChannels());
347+
final Long duration = (Long)fileFormat.getProperty("duration");
348+
assertNotNull(duration);
349+
assertEquals(3030204, (long)duration);
350+
assertEquals((float)NOT_SPECIFIED, format.getFrameRate(), 0.001f);
351+
final Integer bitrate = (Integer)format.getProperty("bitrate");
352+
assertNotNull("Bitrate missing", bitrate);
353+
assertEquals(112000, (int) bitrate);
354+
} finally {
355+
file.delete();
356+
}
357+
}
358+
359+
@Test
360+
public void testGetAudioFileFormatFileWithEmojis() throws IOException, UnsupportedAudioFileException {
361+
// Tests that getAudioFileFormat(File) handles emoji in the file name.
362+
// This path goes through fileToURL(), which must keep emoji percent-encoded
363+
// so JNI's GetStringUTFChars does not produce CESU-8 (Modified UTF-8) bytes
364+
// that differ from the standard UTF-8 bytes used by the file system.
365+
final String filename = "test.ogg";
366+
final File file = File.createTempFile("testGetAudioFileFormatFileWithEmojis🔥", filename);
367+
extractFile(filename, file);
368+
try {
369+
final AudioFileFormat fileFormat = new FFAudioFileReader().getAudioFileFormat(file);
370+
System.out.println(fileFormat);
371+
assertEquals("ogg", fileFormat.getType().getExtension());
372+
assertEquals(2, fileFormat.getFormat().getChannels());
373+
} finally {
374+
file.delete();
375+
}
376+
}
377+
378+
@Test
379+
public void testGetAudioInputStreamFileWithEmojis() throws IOException, UnsupportedAudioFileException {
380+
// Tests that getAudioInputStream(File) can open and read a file whose
381+
// name contains emoji. Exercises the FFURLInputStream.open() JNI call.
382+
final String filename = "test.ogg";
383+
final File file = File.createTempFile("testGetAudioInputStreamFileWithEmojis🎵", filename);
384+
extractFile(filename, file);
385+
try {
386+
final AudioInputStream stream = new FFAudioFileReader().getAudioInputStream(file);
387+
try {
388+
final byte[] buf = new byte[1024];
389+
assertTrue("Expected to read audio bytes from emoji-named file", stream.read(buf) > 0);
390+
} finally {
391+
stream.close();
392+
}
393+
} finally {
394+
file.delete();
395+
}
396+
}
397+
398+
@Test
399+
public void testFileWithEmojiToURL() throws MalformedURLException {
400+
// fileToURL() must produce a valid file: URL for paths containing emoji.
401+
// The native code uses ff_jstring_to_utf8() (String.getBytes("UTF-8")) rather than
402+
// GetStringUTFChars so that supplementary characters like emoji are correctly encoded
403+
// as standard UTF-8 bytes — matching the bytes stored on disk — regardless of whether
404+
// the URL string contains the emoji as a raw character or percent-encoded.
405+
Assume.assumeTrue(File.separator.equals("/"));
406+
final File file = new File("/someDir/test🔥/name.ogg");
407+
final URL url = FFAudioFileReader.fileToURL(file);
408+
assertTrue("URL must use file: protocol: " + url, url.toString().startsWith("file:"));
409+
// The emoji must be represented in the URL in some form (raw or percent-encoded).
410+
assertTrue("URL must contain emoji path component: " + url,
411+
url.toString().contains("test🔥") || url.toString().contains("%F0%9F%94%A5") || url.toString().contains("%f0%9f%94%a5"));
412+
// The path decoded from the URL must end with the original file name.
413+
assertTrue("Decoded path must contain original file name: " + url,
414+
url.getFile().contains("test") && url.getFile().contains("name.ogg"));
415+
}
416+
417+
@Test
418+
public void testGetAudioFileFormatURLWithSpaces() throws IOException, UnsupportedAudioFileException {
419+
// Tests that getAudioFileFormat(URL) handles spaces in the file path.
420+
// file.toURI().toURL() encodes spaces as %20; urlToString() must decode
421+
// them before passing to FFmpeg, which does not percent-decode file: paths.
422+
final String filename = "test.ogg";
423+
final File file = File.createTempFile("test with spaces", filename);
424+
extractFile(filename, file);
425+
try {
426+
final AudioFileFormat fileFormat = new FFAudioFileReader().getAudioFileFormat(file.toURI().toURL());
427+
assertEquals("ogg", fileFormat.getType().getExtension());
428+
assertEquals(2, fileFormat.getFormat().getChannels());
429+
} finally {
430+
file.delete();
431+
}
432+
}
433+
329434
@Test
330435
public void testGetAudioFileFormatInputStream() throws IOException, UnsupportedAudioFileException {
331436
// first copy the file from resources to actual location in temp

ffsampledsp-java/src/main/java/com/tagtraum/ffsampledsp/FFAudioFileReader.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,21 @@ static URL fileToURL(final File file) throws MalformedURLException {
139139
/**
140140
* Make sure that file URLs on Windows follow the super special libav style, e.g. "file:C:/path/file.ext"
141141
* or "file://UNCServerName/path/file.ext".
142+
* For file: URLs on all platforms, percent-encoded sequences (e.g. %20 for space) are decoded
143+
* because FFmpeg's file: protocol handler passes the path directly to the OS without decoding.
142144
*/
143145
static String urlToString(final URL url) {
144146
if (url == null) return null;
145-
final String s = url.toString();
147+
String s = url.toString();
148+
if (s.startsWith("file:")) {
149+
// FFmpeg's file: protocol handler does not percent-decode paths, so decode here.
150+
// Protect '+' first so URLDecoder does not convert it to a space.
151+
try {
152+
s = URLDecoder.decode(s.replace("+", "%2B"), "UTF-8");
153+
} catch (UnsupportedEncodingException e) {
154+
// UTF-8 is always available; cannot happen
155+
}
156+
}
146157
if (WINDOWS && s.matches("file\\:/[^\\/].*")) {
147158
return s.replace("file:/", "file:");
148159
}

ffsampledsp-x86_64-macos/src/main/c/FFAudioFileReader.c

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,16 @@ static int create_ffaudiofileformats(JNIEnv *env, AVFormatContext *format_contex
396396

397397
init_ids(env);
398398

399-
const char *input_url = (*env)->GetStringUTFChars(env, url, NULL);
399+
// Use ff_jstring_to_utf8 instead of GetStringUTFChars: the JNI function returns
400+
// Modified UTF-8 (CESU-8), which encodes supplementary characters (U+10000+, e.g.
401+
// emoji) as 6 bytes rather than the standard UTF-8 4-byte sequence. File systems
402+
// use standard UTF-8, so avformat_open_input would fail to find such files.
403+
char *input_url = ff_jstring_to_utf8(env, url);
404+
if (!input_url) {
405+
throwIOExceptionIfError(env, AVERROR(ENOMEM), "Failed to convert URL to UTF-8");
406+
goto bail;
407+
}
408+
400409
res = ff_open_format_context(env, &format_context, input_url);
401410
if (res) {
402411
goto bail;
@@ -411,7 +420,7 @@ static int create_ffaudiofileformats(JNIEnv *env, AVFormatContext *format_contex
411420
if (format_context) {
412421
avformat_close_input(&format_context);
413422
}
414-
(*env)->ReleaseStringUTFChars(env, url, input_url);
423+
free(input_url);
415424

416425
return array;
417426
}

ffsampledsp-x86_64-macos/src/main/c/FFURLInputStream.c

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,14 @@ JNIEXPORT jlong JNICALL Java_com_tagtraum_ffsampledsp_FFURLInputStream_open(JNIE
5858
int res = 0;
5959
FFAudioIO *aio = NULL;
6060

61-
// copy URL to local char*
62-
const char *input_url = (*env)->GetStringUTFChars(env, url, NULL);
61+
// Use ff_jstring_to_utf8 instead of GetStringUTFChars: the JNI function returns
62+
// Modified UTF-8 (CESU-8), which encodes supplementary characters (U+10000+, e.g.
63+
// emoji) as 6 bytes rather than the standard UTF-8 4-byte sequence. File systems
64+
// use standard UTF-8, so avformat_open_input would fail to find such files.
65+
char *input_url = ff_jstring_to_utf8(env, url);
6366
if (!input_url) {
6467
res = AVERROR(ENOMEM);
65-
throwIOExceptionIfError(env, res, "Failed to get url");
68+
throwIOExceptionIfError(env, res, "Failed to convert URL to UTF-8");
6669
goto bail;
6770
}
6871

@@ -97,8 +100,8 @@ JNIEXPORT jlong JNICALL Java_com_tagtraum_ffsampledsp_FFURLInputStream_open(JNIE
97100
bail:
98101

99102
if (res) ff_audioio_free(aio);
100-
(*env)->ReleaseStringUTFChars(env, url, input_url);
101-
103+
free(input_url);
104+
102105
return (jlong)(intptr_t)aio;
103106
}
104107

ffsampledsp-x86_64-macos/src/main/c/FFUtils.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,4 +1197,40 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
11971197
avformat_network_init();
11981198
//avcodec_register_all();
11991199
return JNI_VERSION_1_6;
1200+
}
1201+
1202+
char *ff_jstring_to_utf8(JNIEnv *env, jstring java_string) {
1203+
jclass string_class = NULL;
1204+
jmethodID get_bytes_mid = NULL;
1205+
jstring utf8_charset = NULL;
1206+
jbyteArray bytes = NULL;
1207+
jsize len = 0;
1208+
char *result = NULL;
1209+
1210+
if (!java_string) return NULL;
1211+
1212+
string_class = (*env)->GetObjectClass(env, java_string);
1213+
if (!string_class) goto bail;
1214+
1215+
get_bytes_mid = (*env)->GetMethodID(env, string_class, "getBytes", "(Ljava/lang/String;)[B");
1216+
if (!get_bytes_mid) goto bail;
1217+
1218+
utf8_charset = (*env)->NewStringUTF(env, "UTF-8");
1219+
if (!utf8_charset) goto bail;
1220+
1221+
bytes = (jbyteArray)(*env)->CallObjectMethod(env, java_string, get_bytes_mid, utf8_charset);
1222+
if (!bytes) goto bail;
1223+
1224+
len = (*env)->GetArrayLength(env, bytes);
1225+
result = (char *)malloc(len + 1);
1226+
if (!result) goto bail;
1227+
1228+
(*env)->GetByteArrayRegion(env, bytes, 0, len, (jbyte *)result);
1229+
result[len] = '\0';
1230+
1231+
bail:
1232+
if (utf8_charset) (*env)->DeleteLocalRef(env, utf8_charset);
1233+
if (bytes) (*env)->DeleteLocalRef(env, bytes);
1234+
1235+
return result;
12001236
}

ffsampledsp-x86_64-macos/src/main/c/FFUtils.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,23 @@ int ff_init_encoder(JNIEnv*, FFAudioIO*, AVCodec*);
116116

117117
int ff_big_endian(enum AVCodecID);
118118

119+
/**
120+
* Converts a Java string to a standard UTF-8 C string.
121+
*
122+
* JNI's GetStringUTFChars returns Modified UTF-8 (CESU-8), which encodes supplementary
123+
* characters (U+10000 and above, e.g. emoji) as two 3-byte sequences (6 bytes total)
124+
* instead of the single 4-byte sequence used by standard UTF-8. File systems on macOS
125+
* and Linux use standard UTF-8 for file names, so paths containing emoji passed through
126+
* GetStringUTFChars will not match the file on disk.
127+
*
128+
* This function calls java.lang.String.getBytes("UTF-8") via JNI, which always returns
129+
* standard UTF-8 bytes regardless of the characters involved.
130+
*
131+
* The caller must free() the returned buffer when no longer needed.
132+
*
133+
* @param env JNI environment
134+
* @param java_string Java String object
135+
* @return Newly allocated null-terminated standard UTF-8 C string, or NULL on error
136+
*/
137+
char *ff_jstring_to_utf8(JNIEnv *env, jstring java_string);
119138

0 commit comments

Comments
 (0)