diff --git a/pom.xml b/pom.xml index 903365a..f68df4e 100644 --- a/pom.xml +++ b/pom.xml @@ -221,6 +221,10 @@ 3.5.2 --enable-native-access=ALL-UNNAMED + + false diff --git a/src/main/java/org/mcaccess/prism/AvailabilityListener.java b/src/main/java/org/mcaccess/prism/AvailabilityListener.java index 616b667..460c853 100644 --- a/src/main/java/org/mcaccess/prism/AvailabilityListener.java +++ b/src/main/java/org/mcaccess/prism/AvailabilityListener.java @@ -1,16 +1,15 @@ package org.mcaccess.prism; /** - * Listener invoked when a backend's availability changes at runtime. + * Notified when a backend's runtime availability changes. + * A call is a notification that the application's cached choice of backend may be stale. It does not change any backend instance the application already holds */ @FunctionalInterface public interface AvailabilityListener { /** - * Called when a backend availability status changes. - * - * @param backend the backend ID (or {@link BackendId#INVALID} if custom/unknown) - * @param name the backend name - * @param available {@code true} if the backend is currently available, {@code false} otherwise + * @param backend The identifier of the backend whose availability changed. + * @param name The backend name, for example "SAPI", "NVDA" or "OneCore". + * @param available True if the backend became available, false if it became unavailable. */ void onAvailabilityChanged(BackendId backend, String name, boolean available); } diff --git a/src/main/java/org/mcaccess/prism/Backend.java b/src/main/java/org/mcaccess/prism/Backend.java index 66422d4..7cd6b9f 100644 --- a/src/main/java/org/mcaccess/prism/Backend.java +++ b/src/main/java/org/mcaccess/prism/Backend.java @@ -8,27 +8,54 @@ import java.lang.foreign.MemorySegment; import java.lang.foreign.ValueLayout; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; +import java.util.function.IntSupplier; +import java.util.function.Supplier; + +import static java.lang.foreign.ValueLayout.ADDRESS; +import static java.lang.foreign.ValueLayout.JAVA_BOOLEAN; +import static java.lang.foreign.ValueLayout.JAVA_FLOAT; +import static java.lang.foreign.ValueLayout.JAVA_LONG; /** * Represents an active PRISM speech or screen reader backend instance. */ public final class Backend implements AutoCloseable { + /** + * the locks use the native instance identity. Keying by address can result in returning new handles to the same cached instance and as the docs state, a backend instance is not thread-safe even for logically independent calls. + * Only created instance key by address. + */ + private static final ConcurrentMap LOCKS = new ConcurrentHashMap<>(); + + @FunctionalInterface + private interface OutCall { + int invoke(MemorySegment out); + } + + @FunctionalInterface + private interface TextCall { + int invoke(Arena arena, MemorySegment text); + } + private final MemorySegment handle; - private final boolean owned; + private final Object lock; private volatile boolean closed = false; - Backend(MemorySegment handle, boolean owned) { - Objects.requireNonNull(handle, "Backend handle must not be null"); - if (handle.equals(MemorySegment.NULL)) { + Backend(MemorySegment handle, Object lockKey) { + if (handle.address() == 0) { throw new IllegalArgumentException("Backend handle must not be NULL"); } NativeLoader.load(); this.handle = handle; - this.owned = owned; + this.lock = LOCKS.computeIfAbsent(lockKey, k -> new Object()); - int res = prism_h.prism_backend_initialize(handle); - if (res != prism_h.PRISM_OK() && res != prism_h.PRISM_ERROR_ALREADY_INITIALIZED()) { - PrismException.throwIfError(res); + synchronized (lock) { + int res = prism_h.prism_backend_initialize(handle); + if (res != prism_h.PRISM_OK() && res != prism_h.PRISM_ERROR_ALREADY_INITIALIZED()) { + PrismException.throwIfError(res); + } } } @@ -48,20 +75,16 @@ public MemorySegment handle() { * @return backend name */ public String getName() { - checkClosed(); - MemorySegment namePtr = prism_h.prism_backend_name(handle); - return readCString(namePtr); + return locked(() -> readCString(prism_h.prism_backend_name(handle))); } /** * Gets the feature flags supported by this backend. * - * @return backend features + * @return the raw feature bitmask; decode it with {@link BackendFeature} */ - public BackendFeatures getFeatures() { - checkClosed(); - long features = prism_h.prism_backend_get_features(handle); - return BackendFeatures.fromBits(features); + public long getFeatures() { + return locked(() -> prism_h.prism_backend_get_features(handle)); } /** @@ -71,13 +94,7 @@ public BackendFeatures getFeatures() { * @param interrupt whether to interrupt ongoing speech */ public void speak(String text, boolean interrupt) { - checkClosed(); - validateText(text); - try (Arena arena = Arena.ofConfined()) { - MemorySegment textSeg = arena.allocateFrom(text); - int res = prism_h.prism_backend_speak(handle, textSeg, interrupt); - PrismException.throwIfError(res); - } + callWithText(text, (arena, seg) -> prism_h.prism_backend_speak(handle, seg, interrupt)); } /** @@ -96,27 +113,18 @@ public void speak(String text) { * @param callback consumer called with audio sample data */ public void speakToMemory(String text, AudioCallback callback) { - checkClosed(); - validateText(text); Objects.requireNonNull(callback, "callback must not be null"); - - try (Arena arena = Arena.ofConfined()) { - MemorySegment textSeg = arena.allocateFrom(text); - - PrismAudioCallback.Function callbackFunc = (userdata, samplesPtr, sampleCount, channels, sampleRate) -> { - int count = (int) sampleCount; - float[] samples = new float[count]; - if (count > 0 && samplesPtr != null && !samplesPtr.equals(MemorySegment.NULL)) { - MemorySegment sizedPtr = samplesPtr.reinterpret((long) count * Float.BYTES); - MemorySegment.copy(sizedPtr, ValueLayout.JAVA_FLOAT, 0, samples, 0, count); - } - callback.onAudioData(samples, (int) channels, (int) sampleRate); - }; - - MemorySegment callbackStub = PrismAudioCallback.allocate(callbackFunc, arena); - int res = prism_h.prism_backend_speak_to_memory(handle, textSeg, callbackStub, MemorySegment.NULL); - PrismException.throwIfError(res); - } + PrismAudioCallback.Function onAudio = (userdata, samplesPtr, sampleCount, channels, sampleRate) -> { + int count = (int) sampleCount; + float[] samples = new float[count]; + if (count > 0 && samplesPtr.address() != 0) { + MemorySegment sized = samplesPtr.reinterpret((long) count * Float.BYTES); + MemorySegment.copy(sized, JAVA_FLOAT, 0, samples, 0, count); + } + callback.onAudioData(samples, (int) channels, (int) sampleRate); + }; + callWithText(text, (arena, seg) -> prism_h.prism_backend_speak_to_memory( + handle, seg, PrismAudioCallback.allocate(onAudio, arena), MemorySegment.NULL)); } /** @@ -125,13 +133,7 @@ public void speakToMemory(String text, AudioCallback callback) { * @param text the text to display in Braille */ public void braille(String text) { - checkClosed(); - validateText(text); - try (Arena arena = Arena.ofConfined()) { - MemorySegment textSeg = arena.allocateFrom(text); - int res = prism_h.prism_backend_braille(handle, textSeg); - PrismException.throwIfError(res); - } + callWithText(text, (arena, seg) -> prism_h.prism_backend_braille(handle, seg)); } /** @@ -141,13 +143,7 @@ public void braille(String text) { * @param interrupt whether to interrupt ongoing speech */ public void output(String text, boolean interrupt) { - checkClosed(); - validateText(text); - try (Arena arena = Arena.ofConfined()) { - MemorySegment textSeg = arena.allocateFrom(text); - int res = prism_h.prism_backend_output(handle, textSeg, interrupt); - PrismException.throwIfError(res); - } + callWithText(text, (arena, seg) -> prism_h.prism_backend_output(handle, seg, interrupt)); } /** @@ -163,27 +159,21 @@ public void output(String text) { * Stops current speech output immediately. */ public void stop() { - checkClosed(); - int res = prism_h.prism_backend_stop(handle); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_stop(handle)); } /** * Pauses ongoing speech synthesis and playback. */ public void pause() { - checkClosed(); - int res = prism_h.prism_backend_pause(handle); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_pause(handle)); } /** * Resumes paused speech playback. */ public void resume() { - checkClosed(); - int res = prism_h.prism_backend_resume(handle); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_resume(handle)); } /** @@ -192,13 +182,7 @@ public void resume() { * @return {@code true} if speaking, {@code false} otherwise */ public boolean isSpeaking() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outSpeaking = arena.allocate(ValueLayout.JAVA_BOOLEAN); - int res = prism_h.prism_backend_is_speaking(handle, outSpeaking); - PrismException.throwIfError(res); - return outSpeaking.get(ValueLayout.JAVA_BOOLEAN, 0); - } + return queryBoolean(out -> prism_h.prism_backend_is_speaking(handle, out)); } /** @@ -207,12 +191,10 @@ public boolean isSpeaking() { * @param volume volume level between 0.0 and 1.0 */ public void setVolume(float volume) { - checkClosed(); if (volume < 0.0f || volume > 1.0f) { throw new PrismException.RangeOutOfBounds("Volume must be between 0.0 and 1.0"); } - int res = prism_h.prism_backend_set_volume(handle, volume); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_set_volume(handle, volume)); } /** @@ -221,13 +203,7 @@ public void setVolume(float volume) { * @return current volume between 0.0 and 1.0 */ public float getVolume() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outVolume = arena.allocate(ValueLayout.JAVA_FLOAT); - int res = prism_h.prism_backend_get_volume(handle, outVolume); - PrismException.throwIfError(res); - return outVolume.get(ValueLayout.JAVA_FLOAT, 0); - } + return queryFloat(out -> prism_h.prism_backend_get_volume(handle, out)); } /** @@ -236,12 +212,10 @@ public float getVolume() { * @param rate speech rate multiplier (e.g. 1.0 is normal rate) */ public void setRate(float rate) { - checkClosed(); if (rate < 0.0f) { throw new PrismException.RangeOutOfBounds("Rate must be non-negative"); } - int res = prism_h.prism_backend_set_rate(handle, rate); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_set_rate(handle, rate)); } /** @@ -250,13 +224,7 @@ public void setRate(float rate) { * @return current speech rate */ public float getRate() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outRate = arena.allocate(ValueLayout.JAVA_FLOAT); - int res = prism_h.prism_backend_get_rate(handle, outRate); - PrismException.throwIfError(res); - return outRate.get(ValueLayout.JAVA_FLOAT, 0); - } + return queryFloat(out -> prism_h.prism_backend_get_rate(handle, out)); } /** @@ -265,12 +233,10 @@ public float getRate() { * @param pitch speech pitch multiplier (e.g. 1.0 is normal pitch) */ public void setPitch(float pitch) { - checkClosed(); if (pitch < 0.0f) { throw new PrismException.RangeOutOfBounds("Pitch must be non-negative"); } - int res = prism_h.prism_backend_set_pitch(handle, pitch); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_set_pitch(handle, pitch)); } /** @@ -279,22 +245,14 @@ public void setPitch(float pitch) { * @return current speech pitch */ public float getPitch() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outPitch = arena.allocate(ValueLayout.JAVA_FLOAT); - int res = prism_h.prism_backend_get_pitch(handle, outPitch); - PrismException.throwIfError(res); - return outPitch.get(ValueLayout.JAVA_FLOAT, 0); - } + return queryFloat(out -> prism_h.prism_backend_get_pitch(handle, out)); } /** * Refreshes the list of available voices. */ public void refreshVoices() { - checkClosed(); - int res = prism_h.prism_backend_refresh_voices(handle); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_refresh_voices(handle)); } /** @@ -303,13 +261,7 @@ public void refreshVoices() { * @return number of voices */ public int getVoicesCount() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outCount = arena.allocate(ValueLayout.JAVA_LONG); - int res = prism_h.prism_backend_count_voices(handle, outCount); - PrismException.throwIfError(res); - return (int) outCount.get(ValueLayout.JAVA_LONG, 0); - } + return (int) queryLong(out -> prism_h.prism_backend_count_voices(handle, out)); } /** @@ -319,14 +271,7 @@ public int getVoicesCount() { * @return voice name */ public String getVoiceName(int voiceIndex) { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outName = arena.allocate(ValueLayout.ADDRESS); - int res = prism_h.prism_backend_get_voice_name(handle, voiceIndex, outName); - PrismException.throwIfError(res); - MemorySegment ptr = outName.get(ValueLayout.ADDRESS, 0); - return readCString(ptr); - } + return queryString(out -> prism_h.prism_backend_get_voice_name(handle, voiceIndex, out)); } /** @@ -336,14 +281,7 @@ public String getVoiceName(int voiceIndex) { * @return voice language code (e.g. "en-US") */ public String getVoiceLanguage(int voiceIndex) { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outLang = arena.allocate(ValueLayout.ADDRESS); - int res = prism_h.prism_backend_get_voice_language(handle, voiceIndex, outLang); - PrismException.throwIfError(res); - MemorySegment ptr = outLang.get(ValueLayout.ADDRESS, 0); - return readCString(ptr); - } + return queryString(out -> prism_h.prism_backend_get_voice_language(handle, voiceIndex, out)); } /** @@ -352,9 +290,7 @@ public String getVoiceLanguage(int voiceIndex) { * @param voiceIndex 0-based voice index */ public void setVoice(int voiceIndex) { - checkClosed(); - int res = prism_h.prism_backend_set_voice(handle, voiceIndex); - PrismException.throwIfError(res); + call(() -> prism_h.prism_backend_set_voice(handle, voiceIndex)); } /** @@ -363,13 +299,7 @@ public void setVoice(int voiceIndex) { * @return active voice index */ public int getVoice() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outVoice = arena.allocate(ValueLayout.JAVA_LONG); - int res = prism_h.prism_backend_get_voice(handle, outVoice); - PrismException.throwIfError(res); - return (int) outVoice.get(ValueLayout.JAVA_LONG, 0); - } + return (int) queryLong(out -> prism_h.prism_backend_get_voice(handle, out)); } /** @@ -378,13 +308,7 @@ public int getVoice() { * @return channel count (1 for mono, 2 for stereo) */ public int getChannels() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outChannels = arena.allocate(ValueLayout.JAVA_LONG); - int res = prism_h.prism_backend_get_channels(handle, outChannels); - PrismException.throwIfError(res); - return (int) outChannels.get(ValueLayout.JAVA_LONG, 0); - } + return (int) queryLong(out -> prism_h.prism_backend_get_channels(handle, out)); } /** @@ -393,13 +317,7 @@ public int getChannels() { * @return sample rate in Hz (e.g. 44100) */ public int getSampleRate() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outSampleRate = arena.allocate(ValueLayout.JAVA_LONG); - int res = prism_h.prism_backend_get_sample_rate(handle, outSampleRate); - PrismException.throwIfError(res); - return (int) outSampleRate.get(ValueLayout.JAVA_LONG, 0); - } + return (int) queryLong(out -> prism_h.prism_backend_get_sample_rate(handle, out)); } /** @@ -408,20 +326,14 @@ public int getSampleRate() { * @return bit depth (e.g. 16, 32) */ public int getBitDepth() { - checkClosed(); - try (Arena arena = Arena.ofConfined()) { - MemorySegment outBitDepth = arena.allocate(ValueLayout.JAVA_LONG); - int res = prism_h.prism_backend_get_bit_depth(handle, outBitDepth); - PrismException.throwIfError(res); - return (int) outBitDepth.get(ValueLayout.JAVA_LONG, 0); - } + return (int) queryLong(out -> prism_h.prism_backend_get_bit_depth(handle, out)); } @Override public void close() { - if (!closed) { - closed = true; - if (owned && handle != null && !handle.equals(MemorySegment.NULL)) { + synchronized (lock) { + if (!closed) { + closed = true; prism_h.prism_backend_free(handle); } } @@ -431,13 +343,62 @@ public boolean isClosed() { return closed; } + private T locked(Supplier action) { + synchronized (lock) { + checkClosed(); + return action.get(); + } + } + + private void call(IntSupplier nativeCall) { + synchronized (lock) { + checkClosed(); + PrismException.throwIfError(nativeCall.getAsInt()); + } + } + + private void callWithText(String text, TextCall nativeCall) { + validateText(text); + call(() -> { + try (Arena arena = Arena.ofConfined()) { + return nativeCall.invoke(arena, arena.allocateFrom(text)); + } + }); + } + + private T query(ValueLayout layout, OutCall nativeCall, Function read) { + return locked(() -> { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(layout); + PrismException.throwIfError(nativeCall.invoke(out)); + return read.apply(out); + } + }); + } + + private float queryFloat(OutCall nativeCall) { + return query(JAVA_FLOAT, nativeCall, out -> out.get(JAVA_FLOAT, 0)); + } + + private long queryLong(OutCall nativeCall) { + return query(JAVA_LONG, nativeCall, out -> out.get(JAVA_LONG, 0)); + } + + private boolean queryBoolean(OutCall nativeCall) { + return query(JAVA_BOOLEAN, nativeCall, out -> out.get(JAVA_BOOLEAN, 0)); + } + + private String queryString(OutCall nativeCall) { + return query(ADDRESS, nativeCall, out -> readCString(out.get(ADDRESS, 0))); + } + private void checkClosed() { if (closed) { throw new IllegalStateException("Backend is already closed"); } } - private void validateText(String text) { + private static void validateText(String text) { if (text == null || text.isEmpty()) { throw new PrismException.InvalidParam("Text must not be null or empty"); } @@ -447,7 +408,7 @@ private void validateText(String text) { } static String readCString(MemorySegment ptr) { - if (ptr == null || ptr.equals(MemorySegment.NULL) || ptr.address() == 0) { + if (ptr.address() == 0) { return ""; } return ptr.reinterpret(Long.MAX_VALUE).getString(0); diff --git a/src/main/java/org/mcaccess/prism/BackendFeature.java b/src/main/java/org/mcaccess/prism/BackendFeature.java new file mode 100644 index 0000000..e827e29 --- /dev/null +++ b/src/main/java/org/mcaccess/prism/BackendFeature.java @@ -0,0 +1,55 @@ +package org.mcaccess.prism; + +import java.util.EnumSet; + +public enum BackendFeature { + IS_SUPPORTED_AT_RUNTIME(1L << 0), SUPPORTS_SPEAK(1L << 2), SUPPORTS_SPEAK_TO_MEMORY(1L << 3), + SUPPORTS_BRAILLE(1L << 4), SUPPORTS_OUTPUT(1L << 5), SUPPORTS_IS_SPEAKING(1L << 6), SUPPORTS_STOP(1L << 7), + SUPPORTS_PAUSE(1L << 8), SUPPORTS_RESUME(1L << 9), SUPPORTS_SET_VOLUME(1L << 10), SUPPORTS_GET_VOLUME(1L << 11), + SUPPORTS_SET_RATE(1L << 12), SUPPORTS_GET_RATE(1L << 13), SUPPORTS_SET_PITCH(1L << 14), + SUPPORTS_GET_PITCH(1L << 15), SUPPORTS_REFRESH_VOICES(1L << 16), SUPPORTS_COUNT_VOICES(1L << 17), + SUPPORTS_GET_VOICE_NAME(1L << 18), SUPPORTS_GET_VOICE_LANGUAGE(1L << 19), SUPPORTS_GET_VOICE(1L << 20), + SUPPORTS_SET_VOICE(1L << 21), SUPPORTS_GET_CHANNELS(1L << 22), SUPPORTS_GET_SAMPLE_RATE(1L << 23), + SUPPORTS_GET_BIT_DEPTH(1L << 24), PERFORMS_SILENCE_TRIMMING_ON_SPEAK(1L << 25), + PERFORMS_SILENCE_TRIMMING_ON_SPEAK_TO_MEMORY(1L << 26), SUPPORTS_SPEAK_SSML(1L << 27), + SUPPORTS_SPEAK_TO_MEMORY_SSML(1L << 28); + + private final long mask; + + BackendFeature(long mask) { + this.mask = mask; + } + + /** + * Gets the raw bitmask value of this feature. + */ + public long getMask() { + return mask; + } + + /** + * Helper method to check if a backend's returned feature-set includes this feature. + * + * @param backendFeatures The raw 64-bit integer returned from prism_backend_get_features. + * @return true if the feature is present. + */ + public boolean isSupportedBy(long backendFeatures) { + return (backendFeatures & this.mask) == this.mask; + } + + /** + * Expands a raw feature bitmask into the set of features it names. Bits this binding does not recognise are ignored. + * + * @param backendFeatures The raw 64-bit value from {@link Backend#getFeatures()}. + * @return A new, caller-owned set. + */ + public static EnumSet decode(long backendFeatures) { + EnumSet features = EnumSet.noneOf(BackendFeature.class); + for (BackendFeature feature : values()) { + if (feature.isSupportedBy(backendFeatures)) { + features.add(feature); + } + } + return features; + } +} \ No newline at end of file diff --git a/src/main/java/org/mcaccess/prism/BackendFeatures.java b/src/main/java/org/mcaccess/prism/BackendFeatures.java deleted file mode 100644 index 4d012f1..0000000 --- a/src/main/java/org/mcaccess/prism/BackendFeatures.java +++ /dev/null @@ -1,130 +0,0 @@ -package org.mcaccess.prism; - -/** - * Represents the feature flags supported by a PRISM backend. - */ -public record BackendFeatures( - boolean isSupportedAtRuntime, - boolean supportsSpeak, - boolean supportsSpeakToMemory, - boolean supportsBraille, - boolean supportsOutput, - boolean supportsIsSpeaking, - boolean supportsStop, - boolean supportsPause, - boolean supportsResume, - boolean supportsSetVolume, - boolean supportsGetVolume, - boolean supportsSetRate, - boolean supportsGetRate, - boolean supportsSetPitch, - boolean supportsGetPitch, - boolean supportsRefreshVoices, - boolean supportsCountVoices, - boolean supportsGetVoiceName, - boolean supportsGetVoiceLanguage, - boolean supportsGetVoice, - boolean supportsSetVoice, - boolean supportsGetChannels, - boolean supportsGetSampleRate, - boolean supportsGetBitDepth, - boolean performsSilenceTrimmingOnSpeak, - boolean performsSilenceTrimmingOnSpeakToMemory, - boolean supportsSpeakSsml, - boolean supportsSpeakToMemorySsml -) { - public static final long BIT_IS_SUPPORTED_AT_RUNTIME = 1L << 0; - public static final long BIT_SUPPORTS_SPEAK = 1L << 2; - public static final long BIT_SUPPORTS_SPEAK_TO_MEMORY = 1L << 3; - public static final long BIT_SUPPORTS_BRAILLE = 1L << 4; - public static final long BIT_SUPPORTS_OUTPUT = 1L << 5; - public static final long BIT_SUPPORTS_IS_SPEAKING = 1L << 6; - public static final long BIT_SUPPORTS_STOP = 1L << 7; - public static final long BIT_SUPPORTS_PAUSE = 1L << 8; - public static final long BIT_SUPPORTS_RESUME = 1L << 9; - public static final long BIT_SUPPORTS_SET_VOLUME = 1L << 10; - public static final long BIT_SUPPORTS_GET_VOLUME = 1L << 11; - public static final long BIT_SUPPORTS_SET_RATE = 1L << 12; - public static final long BIT_SUPPORTS_GET_RATE = 1L << 13; - public static final long BIT_SUPPORTS_SET_PITCH = 1L << 14; - public static final long BIT_SUPPORTS_GET_PITCH = 1L << 15; - public static final long BIT_SUPPORTS_REFRESH_VOICES = 1L << 16; - public static final long BIT_SUPPORTS_COUNT_VOICES = 1L << 17; - public static final long BIT_SUPPORTS_GET_VOICE_NAME = 1L << 18; - public static final long BIT_SUPPORTS_GET_VOICE_LANGUAGE = 1L << 19; - public static final long BIT_SUPPORTS_GET_VOICE = 1L << 20; - public static final long BIT_SUPPORTS_SET_VOICE = 1L << 21; - public static final long BIT_SUPPORTS_GET_CHANNELS = 1L << 22; - public static final long BIT_SUPPORTS_GET_SAMPLE_RATE = 1L << 23; - public static final long BIT_SUPPORTS_GET_BIT_DEPTH = 1L << 24; - public static final long BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK = 1L << 25; - public static final long BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK_TO_MEMORY = 1L << 26; - public static final long BIT_SUPPORTS_SPEAK_SSML = 1L << 27; - public static final long BIT_SUPPORTS_SPEAK_TO_MEMORY_SSML = 1L << 28; - - public static BackendFeatures fromBits(long bits) { - return new BackendFeatures( - (bits & BIT_IS_SUPPORTED_AT_RUNTIME) != 0, - (bits & BIT_SUPPORTS_SPEAK) != 0, - (bits & BIT_SUPPORTS_SPEAK_TO_MEMORY) != 0, - (bits & BIT_SUPPORTS_BRAILLE) != 0, - (bits & BIT_SUPPORTS_OUTPUT) != 0, - (bits & BIT_SUPPORTS_IS_SPEAKING) != 0, - (bits & BIT_SUPPORTS_STOP) != 0, - (bits & BIT_SUPPORTS_PAUSE) != 0, - (bits & BIT_SUPPORTS_RESUME) != 0, - (bits & BIT_SUPPORTS_SET_VOLUME) != 0, - (bits & BIT_SUPPORTS_GET_VOLUME) != 0, - (bits & BIT_SUPPORTS_SET_RATE) != 0, - (bits & BIT_SUPPORTS_GET_RATE) != 0, - (bits & BIT_SUPPORTS_SET_PITCH) != 0, - (bits & BIT_SUPPORTS_GET_PITCH) != 0, - (bits & BIT_SUPPORTS_REFRESH_VOICES) != 0, - (bits & BIT_SUPPORTS_COUNT_VOICES) != 0, - (bits & BIT_SUPPORTS_GET_VOICE_NAME) != 0, - (bits & BIT_SUPPORTS_GET_VOICE_LANGUAGE) != 0, - (bits & BIT_SUPPORTS_GET_VOICE) != 0, - (bits & BIT_SUPPORTS_SET_VOICE) != 0, - (bits & BIT_SUPPORTS_GET_CHANNELS) != 0, - (bits & BIT_SUPPORTS_GET_SAMPLE_RATE) != 0, - (bits & BIT_SUPPORTS_GET_BIT_DEPTH) != 0, - (bits & BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK) != 0, - (bits & BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK_TO_MEMORY) != 0, - (bits & BIT_SUPPORTS_SPEAK_SSML) != 0, - (bits & BIT_SUPPORTS_SPEAK_TO_MEMORY_SSML) != 0 - ); - } - - public long toBits() { - long bits = 0; - if (isSupportedAtRuntime) bits |= BIT_IS_SUPPORTED_AT_RUNTIME; - if (supportsSpeak) bits |= BIT_SUPPORTS_SPEAK; - if (supportsSpeakToMemory) bits |= BIT_SUPPORTS_SPEAK_TO_MEMORY; - if (supportsBraille) bits |= BIT_SUPPORTS_BRAILLE; - if (supportsOutput) bits |= BIT_SUPPORTS_OUTPUT; - if (supportsIsSpeaking) bits |= BIT_SUPPORTS_IS_SPEAKING; - if (supportsStop) bits |= BIT_SUPPORTS_STOP; - if (supportsPause) bits |= BIT_SUPPORTS_PAUSE; - if (supportsResume) bits |= BIT_SUPPORTS_RESUME; - if (supportsSetVolume) bits |= BIT_SUPPORTS_SET_VOLUME; - if (supportsGetVolume) bits |= BIT_SUPPORTS_GET_VOLUME; - if (supportsSetRate) bits |= BIT_SUPPORTS_SET_RATE; - if (supportsGetRate) bits |= BIT_SUPPORTS_GET_RATE; - if (supportsSetPitch) bits |= BIT_SUPPORTS_SET_PITCH; - if (supportsGetPitch) bits |= BIT_SUPPORTS_GET_PITCH; - if (supportsRefreshVoices) bits |= BIT_SUPPORTS_REFRESH_VOICES; - if (supportsCountVoices) bits |= BIT_SUPPORTS_COUNT_VOICES; - if (supportsGetVoiceName) bits |= BIT_SUPPORTS_GET_VOICE_NAME; - if (supportsGetVoiceLanguage) bits |= BIT_SUPPORTS_GET_VOICE_LANGUAGE; - if (supportsGetVoice) bits |= BIT_SUPPORTS_GET_VOICE; - if (supportsSetVoice) bits |= BIT_SUPPORTS_SET_VOICE; - if (supportsGetChannels) bits |= BIT_SUPPORTS_GET_CHANNELS; - if (supportsGetSampleRate) bits |= BIT_SUPPORTS_GET_SAMPLE_RATE; - if (supportsGetBitDepth) bits |= BIT_SUPPORTS_GET_BIT_DEPTH; - if (performsSilenceTrimmingOnSpeak) bits |= BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK; - if (performsSilenceTrimmingOnSpeakToMemory) bits |= BIT_PERFORMS_SILENCE_TRIMMING_ON_SPEAK_TO_MEMORY; - if (supportsSpeakSsml) bits |= BIT_SUPPORTS_SPEAK_SSML; - if (supportsSpeakToMemorySsml) bits |= BIT_SUPPORTS_SPEAK_TO_MEMORY_SSML; - return bits; - } -} diff --git a/src/main/java/org/mcaccess/prism/BackendId.java b/src/main/java/org/mcaccess/prism/BackendId.java index 8dd5d8b..97cbf84 100644 --- a/src/main/java/org/mcaccess/prism/BackendId.java +++ b/src/main/java/org/mcaccess/prism/BackendId.java @@ -1,48 +1,43 @@ package org.mcaccess.prism; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; +/** + * The identifier of a backend registered with PRISM. + */ +public record BackendId(long id) { + /** The identifier PRISM reserves to mean "no backend". */ + public static final BackendId INVALID = new BackendId(0L); -public enum BackendId { - INVALID(0L), - SAPI(0x1D6DF72422CEEE66L), - AV_SPEECH(0x28E3429577805C24L), - VOICE_OVER(0xCB4897961A754BCBL), - SPEECH_DISPATCHER(0xE3D6F895D949EBFEL), - NVDA(0x89CC19C5C4AC1A56L), - JAWS(0xAC3D60E9BD84B53EL), - ONE_CORE(0x6797D32F0D994CB4L), - ORCA(0x10AA1FC05A17F96CL), - ANDROID_SCREEN_READER(0xD199C175AEEC494BL), - ANDROID_TTS(0xBC175831BFE4E5CCL), - WEB_SPEECH(0x3572538D44D44A8FL), - UIA(0x6238F019DB678F8EL), - ZDSR(0x3D93C56C9E7F2A2EL), - ZOOM_TEXT(0xAE439D62DC7B1479L), - BOY_PC_READER(0x285ABA1C16F3300FL), - PC_TALKER(0x344B951962E3B835L), - SENSE_READER(0xED4760890B55C2F2L), - SYSTEM_ACCESS(0x8380F2A37B2C3EB6L), - WINDOW_EYES(0x9120D89908785C13L), - SPIEL(0x478B44F14AD3D89CL); + public static final BackendId SAPI = new BackendId(0x1D6DF72422CEEE66L); + public static final BackendId AV_SPEECH = new BackendId(0x28E3429577805C24L); + public static final BackendId VOICE_OVER = new BackendId(0xCB4897961A754BCBL); + public static final BackendId SPEECH_DISPATCHER = new BackendId(0xE3D6F895D949EBFEL); + public static final BackendId NVDA = new BackendId(0x89CC19C5C4AC1A56L); + public static final BackendId JAWS = new BackendId(0xAC3D60E9BD84B53EL); + public static final BackendId ONE_CORE = new BackendId(0x6797D32F0D994CB4L); + public static final BackendId ORCA = new BackendId(0x10AA1FC05A17F96CL); + public static final BackendId ANDROID_SCREEN_READER = new BackendId(0xD199C175AEEC494BL); + public static final BackendId ANDROID_TTS = new BackendId(0xBC175831BFE4E5CCL); + public static final BackendId WEB_SPEECH = new BackendId(0x3572538D44D44A8FL); + public static final BackendId UIA = new BackendId(0x6238F019DB678F8EL); + public static final BackendId ZDSR = new BackendId(0x3D93C56C9E7F2A2EL); + public static final BackendId ZOOM_TEXT = new BackendId(0xAE439D62DC7B1479L); + public static final BackendId BOY_PC_READER = new BackendId(0x285ABA1C16F3300FL); + public static final BackendId PC_TALKER = new BackendId(0x344B951962E3B835L); + public static final BackendId SENSE_READER = new BackendId(0xED4760890B55C2F2L); + /** Only available if enabled explicitly at build time. */ + public static final BackendId SYSTEM_ACCESS = new BackendId(0x8380F2A37B2C3EB6L); + public static final BackendId WINDOW_EYES = new BackendId(0x9120D89908785C13L); + public static final BackendId SPIEL = new BackendId(0x478B44F14AD3D89CL); - private final long id; - - BackendId(long id) { - this.id = id; - } - - public long getId() { - return id; + /** + * Returns true if this is the reserved "no backend" identifier. + */ + public boolean isInvalid() { + return id == 0L; } - private static final Map BY_ID = Arrays.stream(values()) - .collect(Collectors.toUnmodifiableMap(BackendId::getId, Function.identity())); - - public static Optional fromId(long id) { - return Optional.ofNullable(BY_ID.get(id)); + @Override + public String toString() { + return String.format("0x%016X", id); } -} \ No newline at end of file +} diff --git a/src/main/java/org/mcaccess/prism/BackendInfo.java b/src/main/java/org/mcaccess/prism/BackendInfo.java new file mode 100644 index 0000000..f502f06 --- /dev/null +++ b/src/main/java/org/mcaccess/prism/BackendInfo.java @@ -0,0 +1,13 @@ +package org.mcaccess.prism; + +/** + * Describes a backend registered with PRISM. + *

+ * Whether a backend is usable at this moment is runtime availability, which is reported through {@link AvailabilityListener} and can be read from a live backend through {@link Backend#getFeatures()} and {@link BackendFeature#IS_SUPPORTED_AT_RUNTIME}. + * + * @param id The backend's identifier. + * @param name The human-readable name, for example "NVDA" or "SAPI". + * @param priority The backend's priority; higher is more preferred. + */ +public record BackendInfo(BackendId id, String name, int priority) { +} diff --git a/src/main/java/org/mcaccess/prism/Context.java b/src/main/java/org/mcaccess/prism/Context.java index eb9bd5d..eca37d0 100644 --- a/src/main/java/org/mcaccess/prism/Context.java +++ b/src/main/java/org/mcaccess/prism/Context.java @@ -1,13 +1,18 @@ package org.mcaccess.prism; import org.mcaccess.prism.natives.NativeLoader; +import org.mcaccess.prism.natives.PrismAvailabilityCallback; +import org.mcaccess.prism.natives.PrismConfig; import org.mcaccess.prism.natives.prism_h; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; +import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.function.Consumer; +import java.util.stream.IntStream; /** * The PRISM context manages backend registration and lifecycle. @@ -15,6 +20,7 @@ public final class Context implements AutoCloseable { private final MemorySegment handle; private final Arena arena; + private final boolean polling; private volatile boolean closed = false; /** @@ -30,28 +36,59 @@ public Context() { * @param configurer consumer to configure the context builder */ public Context(Consumer configurer) { - this(buildFromConfigurer(configurer)); - } - - private static Builder buildFromConfigurer(Consumer configurer) { - Objects.requireNonNull(configurer, "configurer must not be null"); - Builder builder = new Builder(); - configurer.accept(builder); - return builder; + this(new Builder().apply(configurer)); } private Context(Builder builder) { NativeLoader.load(); this.arena = Arena.ofShared(); + this.polling = builder.availabilityListener != null; MemorySegment configSeg = prism_h.prism_config_init(arena); + if (this.polling) { + AvailabilityListener listener = builder.availabilityListener; + Executor executor = builder.availabilityExecutor; + MemorySegment stub = PrismAvailabilityCallback.allocate( + (userdata, backend, name, available) -> dispatch(listener, executor, backend, name, available), + arena); + PrismConfig.availability_callback(configSeg, stub); + PrismConfig.availability_userdata(configSeg, MemorySegment.NULL); + PrismConfig.availability_poll_interval_ms(configSeg, builder.pollIntervalMs); + PrismConfig.availability_debounce_samples(configSeg, builder.debounceSamples); + PrismConfig.availability_backoff_max_ms(configSeg, builder.backoffMaxMs); + PrismConfig.availability_auto_power_manage(configSeg, builder.autoPowerManage); + } + this.handle = prism_h.prism_init(configSeg); - if (this.handle == null || this.handle.equals(MemorySegment.NULL)) { + if (this.handle.address() == 0) { arena.close(); throw new PrismException.NotInitialized("PRISM could not be initialized"); } } + /** + * Hands one availability transition to the application. + *

+ * Invoked on PRISM's poll thread, which performs no further scans until this returns and which must never see a + * Java exception. Both concerns are why the listener is optionally routed through an executor and why every + * failure is swallowed here. + */ + private static void dispatch(AvailabilityListener listener, Executor executor, long backend, MemorySegment name, + boolean available) { + BackendId id = new BackendId(backend); + String backendName = Backend.readCString(name); + Runnable task = () -> listener.onAvailabilityChanged(id, backendName, available); + try { + if (executor != null) { + executor.execute(task); + } else { + task.run(); + } + } catch (Throwable ignored) { + // A throw here would unwind into native code. + } + } + public static Builder builder() { return new Builder(); } @@ -76,6 +113,24 @@ public int getBackendsCount() { return (int) prism_h.prism_registry_count(handle); } + /** + * Lists every backend registered with PRISM, in registry index order. + *

+ * Runtime availability is reported through {@link AvailabilityListener}, and can be read from a live backend through {@link Backend#getFeatures()} and {@link BackendFeature#IS_SUPPORTED_AT_RUNTIME}. + * Sort by {@link BackendInfo#priority()} for preference order. + * + * @return an immutable list of the registered backends + */ + public List getRegisteredBackends() { + checkClosed(); + return IntStream.range(0, getBackendsCount()) + .mapToObj(i -> { + BackendId id = getIdOf(i); + return new BackendInfo(id, getNameOf(id), getPriorityOf(id)); + }) + .toList(); + } + /** * Gets the backend ID for the backend at the specified index. * @@ -88,7 +143,7 @@ public BackendId getIdOf(int index) { if (id == 0) { throw new IndexOutOfBoundsException("Invalid backend index: " + index); } - return BackendId.fromId(id).orElse(BackendId.INVALID); + return new BackendId(id); } /** @@ -116,7 +171,7 @@ public Optional findIdOf(String name) { if (id == 0) { return Optional.empty(); } - return Optional.of(BackendId.fromId(id).orElse(BackendId.INVALID)); + return Optional.of(new BackendId(id)); } } @@ -127,8 +182,7 @@ public Optional findIdOf(String name) { * @return backend name */ public String getNameOf(BackendId id) { - Objects.requireNonNull(id, "id must not be null"); - return getNameOf(id.getId()); + return getNameOf(id.id()); } /** @@ -140,7 +194,7 @@ public String getNameOf(BackendId id) { public String getNameOf(long id) { checkClosed(); MemorySegment ptr = prism_h.prism_registry_name(handle, id); - if (ptr == null || ptr.equals(MemorySegment.NULL)) { + if (ptr.address() == 0) { throw new IllegalArgumentException("Backend ID not found: 0x" + Long.toHexString(id)); } return Backend.readCString(ptr); @@ -153,8 +207,7 @@ public String getNameOf(long id) { * @return priority integer (higher means preferred) */ public int getPriorityOf(BackendId id) { - Objects.requireNonNull(id, "id must not be null"); - return getPriorityOf(id.getId()); + return getPriorityOf(id.id()); } /** @@ -175,8 +228,7 @@ public int getPriorityOf(long id) { * @return {@code true} if exists, {@code false} otherwise */ public boolean exists(BackendId id) { - Objects.requireNonNull(id, "id must not be null"); - return exists(id.getId()); + return exists(id.id()); } /** @@ -197,8 +249,7 @@ public boolean exists(long id) { * @return newly created Backend instance */ public Backend create(BackendId id) { - Objects.requireNonNull(id, "id must not be null"); - return create(id.getId()); + return create(id.id()); } /** @@ -210,10 +261,10 @@ public Backend create(BackendId id) { public Backend create(long id) { checkClosed(); MemorySegment ptr = prism_h.prism_registry_create(handle, id); - if (ptr == null || ptr.equals(MemorySegment.NULL)) { + if (ptr.address() == 0) { throw new PrismException.InvalidParam("Invalid or unsupported backend: 0x" + Long.toHexString(id)); } - return new Backend(ptr, true); + return new Backend(ptr, ptr.address()); } /** @@ -224,10 +275,10 @@ public Backend create(long id) { public Backend createBest() { checkClosed(); MemorySegment ptr = prism_h.prism_registry_create_best(handle); - if (ptr == null || ptr.equals(MemorySegment.NULL)) { + if (ptr.address() == 0) { throw new PrismException.BackendNotAvailable("No suitable PRISM backend available on this system"); } - return new Backend(ptr, true); + return new Backend(ptr, ptr.address()); } /** @@ -237,8 +288,7 @@ public Backend createBest() { * @return acquired Backend instance */ public Backend acquire(BackendId id) { - Objects.requireNonNull(id, "id must not be null"); - return acquire(id.getId()); + return acquire(id.id()); } /** @@ -250,10 +300,10 @@ public Backend acquire(BackendId id) { public Backend acquire(long id) { checkClosed(); MemorySegment ptr = prism_h.prism_registry_acquire(handle, id); - if (ptr == null || ptr.equals(MemorySegment.NULL)) { + if (ptr.address() == 0) { throw new PrismException.InvalidParam("Invalid or unsupported backend: 0x" + Long.toHexString(id)); } - return new Backend(ptr, false); + return new Backend(ptr, new BackendId(id)); } /** @@ -264,19 +314,49 @@ public Backend acquire(long id) { public Backend acquireBest() { checkClosed(); MemorySegment ptr = prism_h.prism_registry_acquire_best(handle); - if (ptr == null || ptr.equals(MemorySegment.NULL)) { + if (ptr.address() == 0) { throw new PrismException.BackendNotAvailable("No suitable PRISM backend available on this system"); } - return new Backend(ptr, false); + return new Backend(ptr, getIdOf(Backend.readCString(prism_h.prism_backend_name(ptr)))); + } + + /** + * Pauses the availability poll thread. While paused it performs no scans. + *

+ * A no-op if this context was not configured with an availability listener, or if polling is already paused. + */ + public void pauseAvailabilityPolling() { + checkClosed(); + if (polling) { + prism_h.prism_availability_poll_pause(handle); + } + } + + /** + * Resumes the availability poll thread. + *

+ * On resume PRISM performs an immediate re-synchronising scan rather than waiting for the next interval, and that scan is not debounced: any backend whose availability differs from the state last reported produces a callback at once. A change that occurred and reversed entirely while paused is therefore not reported. + */ + public void resumeAvailabilityPolling() { + checkClosed(); + if (polling) { + prism_h.prism_availability_poll_resume(handle); + } + } + + /** + * Reports whether this build of PRISM honours automatic power management of the poll thread. + */ + public static boolean isAutoPowerManagementSupported() { + NativeLoader.load(); + return prism_h.prism_availability_auto_power_supported(); } @Override public void close() { if (!closed) { closed = true; - if (handle != null && !handle.equals(MemorySegment.NULL)) { - prism_h.prism_shutdown(handle); - } + prism_h.prism_shutdown(handle); arena.close(); } } @@ -295,6 +375,72 @@ private void checkClosed() { * Builder for configuring and creating a {@link Context}. */ public static final class Builder { + AvailabilityListener availabilityListener; + Executor availabilityExecutor; + int pollIntervalMs; + int debounceSamples; + int backoffMaxMs; + boolean autoPowerManage = true; + + /** + * Polls for availability changes and reports each confirmed transition to {@code listener}. + *

+ * Without a listener the context runs no poll thread and incurs no cost. The listener is invoked on PRISM's poll thread, which performs no further scans until it returns, so supply an executor to move any non-trivial work elsewhere. + * + * @param listener Invoked on each confirmed availability transition, or null for no polling. + * @param executor Runs the listener. Pass null to run it directly on PRISM's poll thread. + */ + public Builder availabilityListener(AvailabilityListener listener, Executor executor) { + this.availabilityListener = listener; + this.availabilityExecutor = executor; + return this; + } + + /** + * @param listener Invoked on each confirmed availability transition, or null for no polling. + */ + public Builder availabilityListener(AvailabilityListener listener) { + return availabilityListener(listener, null); + } + + /** + * @param pollIntervalMs Base interval between scans. 0 selects PRISM's default. + */ + public Builder pollIntervalMs(int pollIntervalMs) { + this.pollIntervalMs = pollIntervalMs; + return this; + } + + /** + * @param debounceSamples Consecutive agreeing samples needed before a change is confirmed. 0 selects PRISM's default. + */ + public Builder debounceSamples(int debounceSamples) { + this.debounceSamples = debounceSamples; + return this; + } + + /** + * @param backoffMaxMs Upper bound for adaptive backoff of the interval while availability is unchanging. The interval is exponential and returns to the base interval as soon as any change is observed. + */ + public Builder backoffMaxMs(int backoffMaxMs) { + this.backoffMaxMs = backoffMaxMs; + return this; + } + + /** + * @param autoPowerManage Pause the poll thread automatically across OS suspend and resume. Ignored on builds and platforms without power-management support; see + * {@link Context#isAutoPowerManagementSupported()}. + */ + public Builder autoPowerManage(boolean autoPowerManage) { + this.autoPowerManage = autoPowerManage; + return this; + } + + public Builder apply(Consumer configurer) { + configurer.accept(this); + return this; + } + public Context build() { return new Context(this); } diff --git a/src/main/java/org/mcaccess/prism/PrismError.java b/src/main/java/org/mcaccess/prism/PrismError.java new file mode 100644 index 0000000..d16c5f6 --- /dev/null +++ b/src/main/java/org/mcaccess/prism/PrismError.java @@ -0,0 +1,83 @@ +package org.mcaccess.prism; + +import java.util.function.Function; + +public enum PrismError { + OK(0, null), + NOT_INITIALIZED(1, PrismException.NotInitialized::new), + INVALID_PARAM(2, PrismException.InvalidParam::new), + NOT_IMPLEMENTED(3, PrismException.NotImplemented::new), + NO_VOICES(4, PrismException.NoVoices::new), + VOICE_NOT_FOUND(5, PrismException.VoiceNotFound::new), + SPEAK_FAILURE(6, PrismException.SpeakFailure::new), + MEMORY_FAILURE(7, PrismException.MemoryFailure::new), + RANGE_OUT_OF_BOUNDS(8, PrismException.RangeOutOfBounds::new), + INTERNAL(9, PrismException.Internal::new), + NOT_SPEAKING(10, PrismException.NotSpeaking::new), + NOT_PAUSED(11, PrismException.NotPaused::new), + ALREADY_PAUSED(12, PrismException.AlreadyPaused::new), + INVALID_UTF8(13, PrismException.InvalidUtf8::new), + INVALID_OPERATION(14, PrismException.InvalidOperation::new), + ALREADY_INITIALIZED(15, PrismException.AlreadyInitialized::new), + BACKEND_NOT_AVAILABLE(16, PrismException.BackendNotAvailable::new), + UNKNOWN(17, PrismException.Unknown::new), + INVALID_AUDIO_FORMAT(18, PrismException.InvalidAudioFormat::new), + INTERNAL_BACKEND_LIMIT_EXCEEDED(19, PrismException.InternalBackendLimitExceeded::new), + BACKEND_ENTERED_UNDEFINED_STATE(20, PrismException.BackendEnteredUndefinedState::new), + LIBRARY_LOAD_FAILED(21, PrismException.LibraryLoadFailed::new), + LIBRARY_INVALID(22, PrismException.LibraryInvalid::new), + INCOMPATIBLE_ABI(23, PrismException.IncompatibleAbi::new); + + private final int code; + private final Function factory; + + PrismError(int code, Function factory) { + this.code = code; + this.factory = factory; + } + + /** + * Gets the raw integer error code corresponding to the native C enum value. + */ + public int getCode() { + return code; + } + + /** + * Returns true if this error represents successful operation (PRISM_OK). + */ + public boolean isSuccess() { + return this == OK; + } + + /** + * Creates the exception type that represents this error. + * + * @param message The human-readable description, normally from prism_error_string. + * @return A new exception of the subtype matching this error. + * @throws IllegalStateException if called on {@link #OK}, which is not an error. + */ + public PrismException newException(String message) { + if (factory == null) { + throw new IllegalStateException("PRISM_OK does not represent an error"); + } + return factory.apply(message); + } + + /** + * Cached because {@link #values()} clones its array on every call, and this is on the path of every native result that is not OK. + */ + private static final PrismError[] BY_CODE = values(); + + /** + * Converts a raw native integer error code into a type-safe PrismError. + *

+ * the C enum is contiguous, so it can be indexed. Newer values will throw unknown. + * + * @param code The integer error code returned from a native C function. + * @return The corresponding PrismError, or {@link #UNKNOWN} if the code is unrecognised. + */ + public static PrismError fromCode(int code) { + return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : UNKNOWN; + } +} diff --git a/src/main/java/org/mcaccess/prism/PrismException.java b/src/main/java/org/mcaccess/prism/PrismException.java index 1c3f422..5fe480b 100644 --- a/src/main/java/org/mcaccess/prism/PrismException.java +++ b/src/main/java/org/mcaccess/prism/PrismException.java @@ -21,32 +21,7 @@ public static void throwIfError(int result) { message = "Unknown PRISM error (code " + result + ")"; } - throw switch (result) { - case 1 -> new NotInitialized(message); - case 2 -> new InvalidParam(message); - case 3 -> new NotImplemented(message); - case 4 -> new NoVoices(message); - case 5 -> new VoiceNotFound(message); - case 6 -> new SpeakFailure(message); - case 7 -> new MemoryFailure(message); - case 8 -> new RangeOutOfBounds(message); - case 9 -> new Internal(message); - case 10 -> new NotSpeaking(message); - case 11 -> new NotPaused(message); - case 12 -> new AlreadyPaused(message); - case 13 -> new InvalidUtf8(message); - case 14 -> new InvalidOperation(message); - case 15 -> new AlreadyInitialized(message); - case 16 -> new BackendNotAvailable(message); - case 17 -> new Unknown(message); - case 18 -> new InvalidAudioFormat(message); - case 19 -> new InternalBackendLimitExceeded(message); - case 20 -> new BackendEnteredUndefinedState(message); - case 21 -> new LibraryLoadFailed(message); - case 22 -> new LibraryInvalid(message); - case 23 -> new IncompatibleAbi(message); - default -> new PrismException(message); - }; + throw PrismError.fromCode(result).newException(message); } /** diff --git a/src/main/java/org/mcaccess/prism/PrismLog.java b/src/main/java/org/mcaccess/prism/PrismLog.java new file mode 100644 index 0000000..5f2e111 --- /dev/null +++ b/src/main/java/org/mcaccess/prism/PrismLog.java @@ -0,0 +1,165 @@ +package org.mcaccess.prism; + +import org.mcaccess.prism.natives.NativeLoader; +import org.mcaccess.prism.natives.PrismLogCallback; +import org.mcaccess.prism.natives.PrismLogHandler; +import org.mcaccess.prism.natives.prism_h; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; + +/** + * Exposes prism's logging. All of these are safe to call from any thread, and before a {@link Context} is created or after it is closed. + */ +public final class PrismLog { + + /** + * Severity levels, ordered from most to least verbose. {@link #NONE} silences logging entirely. + */ + public enum Level { + TRACE(0), + DEBUG(1), + INFO(2), + WARN(3), + ERROR(4), + NONE(5); + + private final int code; + + Level(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + private static final Level[] BY_CODE = values(); + + /** + * Maps a native {@code PrismLogLevel} to a Level. + *

+ * Codes are contiguous from 0 so a constant's code is its index. If the code is not known, returns None. + */ + static Level fromCode(int code) { + return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : NONE; + } + } + + /** + * In the Prism documentation it is said that replacing a listener does not immediately stop sending messages that were queued. We also do not have a way to check whether everything drained. + * Thus, we choose an automatic arena with a list of listener segments to ensure that they are not dropped, crashing the process. This should not grow by any significant magnitude. + */ + private static final Arena STUB_ARENA = Arena.ofAuto(); + private static final List INSTALLED = new ArrayList<>(); + + /** + * Routes PRISM's diagnostic output to {@code listener}. + *

+ * Prism will not log anything without setting a listener. + * Typical usage: + * + *

{@code
+     * PrismLog.setListener((level, source, message) -> {
+     *     switch (level) {
+     *         case ERROR -> LOGGER.error("[prism/{}] {}", source, message);
+     *         case WARN  -> LOGGER.warn("[prism/{}] {}", source, message);
+     *         default    -> LOGGER.info("[prism/{}] {}", source, message);
+     *     }
+     * });
+     * }
+ * + * The listener is called from PRISM's logging thread and must not call back into {@link PrismLog}. + * + * @param listener Receives each message, or null to stop delivery. + */ + public static synchronized void setListener(PrismLogListener listener) { + NativeLoader.load(); + MemorySegment handler = PrismLogHandler.allocate(STUB_ARENA); + INSTALLED.add(handler); + + if (listener == null) { + PrismLogHandler.fn(handler, MemorySegment.NULL); + } else { + MemorySegment stub = PrismLogCallback.allocate( + (userdata, level, source, message) -> deliver(listener, level, source, message), STUB_ARENA); + INSTALLED.add(stub); + PrismLogHandler.fn(handler, stub); + } + PrismLogHandler.userdata(handler, MemorySegment.NULL); + + // in prism_set_log_handler, The previous handler is returned by value and dropped, so capture it in a temporary discarded arena. + try (Arena discarded = Arena.ofConfined()) { + prism_h.prism_set_log_handler(discarded, handler); + } + } + + private static void deliver(PrismLogListener listener, int level, MemorySegment source, MemorySegment message) { + try { + listener.onLog(Level.fromCode(level), Backend.readCString(source), Backend.readCString(message)); + } catch (Throwable t) { + // Must not propagate into PRISM's logging thread, and must not be reported through PRISM. + Thread current = Thread.currentThread(); + Thread.UncaughtExceptionHandler h = current.getUncaughtExceptionHandler(); + if (h != null) { + h.uncaughtException(current, t); + } + } + } + + /** + * Stops delivery of PRISM's diagnostic output. Equivalent to {@code setListener(null)}. + */ + public static void clearListener() { + setListener(null); + } + + /** + * Sets the minimum level PRISM will emit. + * + * @return The level that was previously in effect. + */ + public static Level setLevel(Level level) { + if (level == null) { + throw new NullPointerException("level must not be null"); + } + NativeLoader.load(); + return Level.fromCode(prism_h.prism_set_log_level(level.getCode())); + } + + /** + * Writes a message into PRISM's log, so application events interleave with PRISM's own. + * + * @param level The severity to record it at. + * @param source A short tag identifying the origin, for example the mod id. + * @param message The message. + */ + public static void log(Level level, String source, String message) { + if (level == null) { + throw new NullPointerException("level must not be null"); + } + if (source == null) { + throw new NullPointerException("source must not be null"); + } + if (message == null) { + throw new NullPointerException("message must not be null"); + } + NativeLoader.load(); + try (Arena arena = Arena.ofConfined()) { + prism_h.prism_log(level.getCode(), arena.allocateFrom(source), arena.allocateFrom(message)); + } + } + + /** + * Blocks until everything already queued has been written. PRISM logs on an internal thread, so a crash can otherwise lose the last few messages. + */ + public static void flush() { + NativeLoader.load(); + prism_h.prism_log_flush(); + } + + private PrismLog() { + } +} diff --git a/src/main/java/org/mcaccess/prism/PrismLogListener.java b/src/main/java/org/mcaccess/prism/PrismLogListener.java new file mode 100644 index 0000000..e871d2a --- /dev/null +++ b/src/main/java/org/mcaccess/prism/PrismLogListener.java @@ -0,0 +1,19 @@ +package org.mcaccess.prism; + +/** + * Receives PRISM's internal diagnostic messages. + *

+ * PRISM discards its own log output until a listener is installed, so this is the only way to see what it is logging. Install one with {@link PrismLog#setListener(PrismLogListener)}. + *

+ * Invoked from PRISM's logging thread. Avoid invoking it concurrently with itself, or from a thread the application owns. + * Implementations must synchronise any shared state they touch, and must not call back into {@link PrismLog} or any other PRISM logging function. + */ +@FunctionalInterface +public interface PrismLogListener { + /** + * @param level Severity of the message. + * @param source The PRISM subsystem that emitted it. + * @param message The message text. + */ + void onLog(PrismLog.Level level, String source, String message); +} diff --git a/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java b/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java new file mode 100644 index 0000000..e281c45 --- /dev/null +++ b/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java @@ -0,0 +1,112 @@ +package org.mcaccess.prism; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BackendConcurrencyTest { + + private static final int THREADS = 8; + private static final int ITERATIONS = 400; + + @Test + void oneBackendSurvivesConcurrentIndependentOperations() throws Exception { + try (Context ctx = new Context(); Backend backend = ctx.acquireBest()) { + long features = backend.getFeatures(); + runConcurrently(backend, backend, features); + } + } + + @Test + void twoHandlesToTheSameCachedInstanceSurviveConcurrentUse() throws Exception { + try (Context ctx = new Context(); + Backend a = ctx.acquireBest(); + Backend b = ctx.acquireBest()) { + assertThat(a.getName()).isEqualTo(b.getName()); + runConcurrently(a, b, a.getFeatures()); + } + } + + private void runConcurrently(Backend first, Backend second, long features) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + List failures = new CopyOnWriteArrayList<>(); + try { + List> futures = new java.util.ArrayList<>(); + for (int t = 0; t < THREADS; t++) { + Backend target = (t % 2 == 0) ? first : second; + futures.add(pool.submit((Callable) () -> { + start.await(); + for (int i = 0; i < ITERATIONS; i++) { + try { + target.getName(); + target.getFeatures(); + if (BackendFeature.SUPPORTS_IS_SPEAKING.isSupportedBy(features)) { + target.isSpeaking(); + } + if (BackendFeature.SUPPORTS_GET_VOLUME.isSupportedBy(features)) { + target.getVolume(); + } + if (BackendFeature.SUPPORTS_GET_RATE.isSupportedBy(features)) { + target.getRate(); + } + if (BackendFeature.SUPPORTS_COUNT_VOICES.isSupportedBy(features)) { + target.getVoicesCount(); + } + } catch (PrismException expected) { + // A backend may legitimately refuse an operation; only crashes and races matter here. + } catch (Throwable t2) { + failures.add(t2); + return null; + } + } + return null; + })); + } + start.countDown(); + for (Future f : futures) { + f.get(60, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + assertThat(failures).isEmpty(); + } + + @Test + void closeIsIdempotentUnderConcurrency() throws Exception { + try (Context ctx = new Context()) { + Backend backend = ctx.acquireBest(); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = new java.util.ArrayList<>(); + for (int t = 0; t < THREADS; t++) { + futures.add(pool.submit((Callable) () -> { + start.await(); + backend.close(); + return null; + })); + } + start.countDown(); + for (Future f : futures) { + f.get(30, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + assertThat(backend.isClosed()).isTrue(); + } + } +} diff --git a/src/test/java/org/mcaccess/prism/BackendFeatureTest.java b/src/test/java/org/mcaccess/prism/BackendFeatureTest.java new file mode 100644 index 0000000..8736d6f --- /dev/null +++ b/src/test/java/org/mcaccess/prism/BackendFeatureTest.java @@ -0,0 +1,61 @@ +package org.mcaccess.prism; + +import java.util.EnumSet; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BackendFeatureTest { + + @Test + void eachFeatureIsNamedByItsOwnBitAlone() { + for (BackendFeature feature : BackendFeature.values()) { + assertThat(BackendFeature.decode(feature.getMask())) + .as("%s must decode to exactly itself", feature) + .containsExactly(feature); + } + } + + @Test + void masksAreDistinctAndSingleBit() { + long seen = 0L; + for (BackendFeature feature : BackendFeature.values()) { + long mask = feature.getMask(); + assertThat(Long.bitCount(mask)).as("%s must occupy one bit", feature).isEqualTo(1); + assertThat(seen & mask).as("%s must not reuse another feature's bit", feature).isZero(); + seen |= mask; + } + } + + @Test + void bitOneStaysReserved() { + assertThat(BackendFeature.decode(1L << 1)).isEmpty(); + } + + @Test + void emptyMaskNamesNothing() { + assertThat(BackendFeature.decode(0L)).isEmpty(); + for (BackendFeature feature : BackendFeature.values()) { + assertThat(feature.isSupportedBy(0L)).isFalse(); + } + } + + @Test + void fullMaskNamesEverything() { + long all = 0L; + for (BackendFeature feature : BackendFeature.values()) { + all |= feature.getMask(); + } + assertThat(BackendFeature.decode(all)) + .isEqualTo(EnumSet.allOf(BackendFeature.class)); + } + + @Test + void unrecognisedBitsAreIgnored() { + long unknown = 1L << 40; + assertThat(BackendFeature.decode(unknown)).isEmpty(); + assertThat(BackendFeature.decode(BackendFeature.SUPPORTS_SPEAK.getMask() | unknown)) + .containsExactly(BackendFeature.SUPPORTS_SPEAK); + } +} diff --git a/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java b/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java deleted file mode 100644 index 729fae0..0000000 --- a/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.mcaccess.prism; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class BackendFeaturesTest { - - @Test - void testEmptyFeatures() { - BackendFeatures features = BackendFeatures.fromBits(0L); - assertThat(features.isSupportedAtRuntime()).isFalse(); - assertThat(features.supportsSpeak()).isFalse(); - assertThat(features.supportsSpeakToMemory()).isFalse(); - assertThat(features.supportsBraille()).isFalse(); - assertThat(features.supportsOutput()).isFalse(); - assertThat(features.supportsIsSpeaking()).isFalse(); - assertThat(features.supportsStop()).isFalse(); - assertThat(features.supportsPause()).isFalse(); - assertThat(features.supportsResume()).isFalse(); - assertThat(features.supportsSetVolume()).isFalse(); - assertThat(features.supportsGetVolume()).isFalse(); - assertThat(features.supportsSetRate()).isFalse(); - assertThat(features.supportsGetRate()).isFalse(); - assertThat(features.supportsSetPitch()).isFalse(); - assertThat(features.supportsGetPitch()).isFalse(); - assertThat(features.supportsRefreshVoices()).isFalse(); - assertThat(features.supportsCountVoices()).isFalse(); - assertThat(features.supportsGetVoiceName()).isFalse(); - assertThat(features.supportsGetVoiceLanguage()).isFalse(); - assertThat(features.supportsGetVoice()).isFalse(); - assertThat(features.supportsSetVoice()).isFalse(); - assertThat(features.supportsGetChannels()).isFalse(); - assertThat(features.supportsGetSampleRate()).isFalse(); - assertThat(features.supportsGetBitDepth()).isFalse(); - assertThat(features.performsSilenceTrimmingOnSpeak()).isFalse(); - assertThat(features.performsSilenceTrimmingOnSpeakToMemory()).isFalse(); - assertThat(features.supportsSpeakSsml()).isFalse(); - assertThat(features.supportsSpeakToMemorySsml()).isFalse(); - assertThat(features.toBits()).isEqualTo(0L); - } - - @Test - void testIndividualFeatureBits() { - BackendFeatures features = BackendFeatures.fromBits( - BackendFeatures.BIT_IS_SUPPORTED_AT_RUNTIME | - BackendFeatures.BIT_SUPPORTS_SPEAK | - BackendFeatures.BIT_SUPPORTS_SET_VOLUME | - BackendFeatures.BIT_SUPPORTS_GET_VOLUME - ); - - assertThat(features.isSupportedAtRuntime()).isTrue(); - assertThat(features.supportsSpeak()).isTrue(); - assertThat(features.supportsSetVolume()).isTrue(); - assertThat(features.supportsGetVolume()).isTrue(); - assertThat(features.supportsBraille()).isFalse(); - assertThat(features.supportsStop()).isFalse(); - - long expectedBits = BackendFeatures.BIT_IS_SUPPORTED_AT_RUNTIME | - BackendFeatures.BIT_SUPPORTS_SPEAK | - BackendFeatures.BIT_SUPPORTS_SET_VOLUME | - BackendFeatures.BIT_SUPPORTS_GET_VOLUME; - - assertThat(features.toBits()).isEqualTo(expectedBits); - } - - @Test - void testAllFeatureBitsRoundTrip() { - long allBits = (1L << 0) | (1L << 2) | (1L << 3) | (1L << 4) | (1L << 5) | - (1L << 6) | (1L << 7) | (1L << 8) | (1L << 9) | (1L << 10) | - (1L << 11) | (1L << 12) | (1L << 13) | (1L << 14) | (1L << 15) | - (1L << 16) | (1L << 17) | (1L << 18) | (1L << 19) | (1L << 20) | - (1L << 21) | (1L << 22) | (1L << 23) | (1L << 24) | (1L << 25) | - (1L << 26) | (1L << 27) | (1L << 28); - - BackendFeatures features = BackendFeatures.fromBits(allBits); - assertThat(features.isSupportedAtRuntime()).isTrue(); - assertThat(features.supportsSpeak()).isTrue(); - assertThat(features.supportsSpeakToMemory()).isTrue(); - assertThat(features.supportsBraille()).isTrue(); - assertThat(features.supportsOutput()).isTrue(); - assertThat(features.supportsIsSpeaking()).isTrue(); - assertThat(features.supportsStop()).isTrue(); - assertThat(features.supportsPause()).isTrue(); - assertThat(features.supportsResume()).isTrue(); - assertThat(features.supportsSetVolume()).isTrue(); - assertThat(features.supportsGetVolume()).isTrue(); - assertThat(features.supportsSetRate()).isTrue(); - assertThat(features.supportsGetRate()).isTrue(); - assertThat(features.supportsSetPitch()).isTrue(); - assertThat(features.supportsGetPitch()).isTrue(); - assertThat(features.supportsRefreshVoices()).isTrue(); - assertThat(features.supportsCountVoices()).isTrue(); - assertThat(features.supportsGetVoiceName()).isTrue(); - assertThat(features.supportsGetVoiceLanguage()).isTrue(); - assertThat(features.supportsGetVoice()).isTrue(); - assertThat(features.supportsSetVoice()).isTrue(); - assertThat(features.supportsGetChannels()).isTrue(); - assertThat(features.supportsGetSampleRate()).isTrue(); - assertThat(features.supportsGetBitDepth()).isTrue(); - assertThat(features.performsSilenceTrimmingOnSpeak()).isTrue(); - assertThat(features.performsSilenceTrimmingOnSpeakToMemory()).isTrue(); - assertThat(features.supportsSpeakSsml()).isTrue(); - assertThat(features.supportsSpeakToMemorySsml()).isTrue(); - - assertThat(features.toBits()).isEqualTo(allBits); - } -} diff --git a/src/test/java/org/mcaccess/prism/BackendIdTest.java b/src/test/java/org/mcaccess/prism/BackendIdTest.java index 0aaa928..290e391 100644 --- a/src/test/java/org/mcaccess/prism/BackendIdTest.java +++ b/src/test/java/org/mcaccess/prism/BackendIdTest.java @@ -2,42 +2,58 @@ import org.junit.jupiter.api.Test; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; class BackendIdTest { @Test - void testAllKnownBackendIds() { - assertThat(BackendId.INVALID.getId()).isEqualTo(0L); - assertThat(BackendId.SAPI.getId()).isEqualTo(0x1D6DF72422CEEE66L); - assertThat(BackendId.AV_SPEECH.getId()).isEqualTo(0x28E3429577805C24L); - assertThat(BackendId.VOICE_OVER.getId()).isEqualTo(0xCB4897961A754BCBL); - assertThat(BackendId.SPEECH_DISPATCHER.getId()).isEqualTo(0xE3D6F895D949EBFEL); - assertThat(BackendId.NVDA.getId()).isEqualTo(0x89CC19C5C4AC1A56L); - assertThat(BackendId.JAWS.getId()).isEqualTo(0xAC3D60E9BD84B53EL); - assertThat(BackendId.ONE_CORE.getId()).isEqualTo(0x6797D32F0D994CB4L); - assertThat(BackendId.ORCA.getId()).isEqualTo(0x10AA1FC05A17F96CL); - assertThat(BackendId.ANDROID_SCREEN_READER.getId()).isEqualTo(0xD199C175AEEC494BL); - assertThat(BackendId.ANDROID_TTS.getId()).isEqualTo(0xBC175831BFE4E5CCL); - assertThat(BackendId.WEB_SPEECH.getId()).isEqualTo(0x3572538D44D44A8FL); - assertThat(BackendId.UIA.getId()).isEqualTo(0x6238F019DB678F8EL); - assertThat(BackendId.ZDSR.getId()).isEqualTo(0x3D93C56C9E7F2A2EL); - assertThat(BackendId.ZOOM_TEXT.getId()).isEqualTo(0xAE439D62DC7B1479L); - assertThat(BackendId.BOY_PC_READER.getId()).isEqualTo(0x285ABA1C16F3300FL); - assertThat(BackendId.PC_TALKER.getId()).isEqualTo(0x344B951962E3B835L); - assertThat(BackendId.SENSE_READER.getId()).isEqualTo(0xED4760890B55C2F2L); - assertThat(BackendId.SYSTEM_ACCESS.getId()).isEqualTo(0x8380F2A37B2C3EB6L); - assertThat(BackendId.WINDOW_EYES.getId()).isEqualTo(0x9120D89908785C13L); - assertThat(BackendId.SPIEL.getId()).isEqualTo(0x478B44F14AD3D89CL); + void knownIdentifiersMatchPrism() { + assertThat(BackendId.INVALID.id()).isEqualTo(0L); + assertThat(BackendId.SAPI.id()).isEqualTo(0x1D6DF72422CEEE66L); + assertThat(BackendId.AV_SPEECH.id()).isEqualTo(0x28E3429577805C24L); + assertThat(BackendId.VOICE_OVER.id()).isEqualTo(0xCB4897961A754BCBL); + assertThat(BackendId.SPEECH_DISPATCHER.id()).isEqualTo(0xE3D6F895D949EBFEL); + assertThat(BackendId.NVDA.id()).isEqualTo(0x89CC19C5C4AC1A56L); + assertThat(BackendId.JAWS.id()).isEqualTo(0xAC3D60E9BD84B53EL); + assertThat(BackendId.ONE_CORE.id()).isEqualTo(0x6797D32F0D994CB4L); + assertThat(BackendId.ORCA.id()).isEqualTo(0x10AA1FC05A17F96CL); + assertThat(BackendId.ANDROID_SCREEN_READER.id()).isEqualTo(0xD199C175AEEC494BL); + assertThat(BackendId.ANDROID_TTS.id()).isEqualTo(0xBC175831BFE4E5CCL); + assertThat(BackendId.WEB_SPEECH.id()).isEqualTo(0x3572538D44D44A8FL); + assertThat(BackendId.UIA.id()).isEqualTo(0x6238F019DB678F8EL); + assertThat(BackendId.ZDSR.id()).isEqualTo(0x3D93C56C9E7F2A2EL); + assertThat(BackendId.ZOOM_TEXT.id()).isEqualTo(0xAE439D62DC7B1479L); + assertThat(BackendId.BOY_PC_READER.id()).isEqualTo(0x285ABA1C16F3300FL); + assertThat(BackendId.PC_TALKER.id()).isEqualTo(0x344B951962E3B835L); + assertThat(BackendId.SENSE_READER.id()).isEqualTo(0xED4760890B55C2F2L); + assertThat(BackendId.SYSTEM_ACCESS.id()).isEqualTo(0x8380F2A37B2C3EB6L); + assertThat(BackendId.WINDOW_EYES.id()).isEqualTo(0x9120D89908785C13L); + assertThat(BackendId.SPIEL.id()).isEqualTo(0x478B44F14AD3D89CL); + } + + @Test + void unknownIdentifiersSurviveIntact() { + long custom = 0x1122334455667788L; + assertThat(new BackendId(custom).id()).isEqualTo(custom); + assertThat(new BackendId(custom)).isNotEqualTo(BackendId.INVALID); + } + + @Test + void equalityIsByValue() { + assertThat(new BackendId(0x89CC19C5C4AC1A56L)).isEqualTo(BackendId.NVDA); + assertThat(new BackendId(0x89CC19C5C4AC1A56L)).hasSameHashCodeAs(BackendId.NVDA); + } + + @Test + void invalidIsTheZeroIdentifier() { + assertThat(BackendId.INVALID.isInvalid()).isTrue(); + assertThat(new BackendId(0L).isInvalid()).isTrue(); + assertThat(BackendId.NVDA.isInvalid()).isFalse(); } @Test - void testFromId() { - assertThat(BackendId.fromId(0x1D6DF72422CEEE66L)).contains(BackendId.SAPI); - assertThat(BackendId.fromId(0x89CC19C5C4AC1A56L)).contains(BackendId.NVDA); - assertThat(BackendId.fromId(0x6797D32F0D994CB4L)).contains(BackendId.ONE_CORE); - assertThat(BackendId.fromId(0x123456789L)).isEmpty(); + void toStringIsFixedWidthHex() { + assertThat(BackendId.NVDA).hasToString("0x89CC19C5C4AC1A56"); + assertThat(BackendId.INVALID).hasToString("0x0000000000000000"); } } diff --git a/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java b/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java new file mode 100644 index 0000000..067c1cb --- /dev/null +++ b/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java @@ -0,0 +1,131 @@ +package org.mcaccess.prism; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises availability polling against a single shared context. + *

+ * Deliberately not one context per test: PRISM crashes when a context configured with an availability callback is + * shut down and another is then created and shut down in the same process, so every polling test here shares one + * context that is closed once at the end. Contexts without a callback are unaffected and are created freely. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ContextAvailabilityTest { + + private static final List EVENTS = new CopyOnWriteArrayList<>(); + private static final CountDownLatch SCANNED = new CountDownLatch(1); + + private ExecutorService executor; + private Context polling; + + @BeforeAll + void startPolling() { + PrismLog.setListener((level, source, message) -> { + if (message.contains("Scanning instance slot")) { + SCANNED.countDown(); + } + }); + PrismLog.setLevel(PrismLog.Level.TRACE); + + executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "availability-dispatch")); + polling = Context.builder() + .availabilityListener((id, name, available) -> EVENTS.add(name + "=" + available), executor) + .pollIntervalMs(0) + .debounceSamples(1) + .backoffMaxMs(0) + .autoPowerManage(true) + .build(); + } + + @AfterAll + void stopPolling() throws Exception { + polling.close(); + assertThat(polling.isClosed()).isTrue(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + PrismLog.clearListener(); + PrismLog.setLevel(PrismLog.Level.NONE); + } + + @AfterEach + void resumeIfPaused() { + polling.resumeAvailabilityPolling(); + } + + @Test + void configuringAListenerStartsThePollThread() throws Exception { + assertThat(SCANNED.await(6, TimeUnit.SECONDS)) + .as("PRISM must run its poll thread once an availability callback is configured") + .isTrue(); + } + + @Test + void pausingAndResumingIsIdempotent() { + polling.pauseAvailabilityPolling(); + polling.pauseAvailabilityPolling(); + polling.resumeAvailabilityPolling(); + polling.resumeAvailabilityPolling(); + assertThat(polling.isClosed()).isFalse(); + } + + @Test + void backendsRemainUsableWhilePolling() { + try (Backend backend = polling.acquireBest()) { + assertThat(backend.getName()).isNotBlank(); + assertThat(BackendFeature.IS_SUPPORTED_AT_RUNTIME.isSupportedBy(backend.getFeatures())).isTrue(); + } + } + + @Test + void registryQueriesWorkWhilePolling() { + assertThat(polling.getBackendsCount()).isPositive(); + BackendId first = polling.getIdOf(0); + assertThat(first.isInvalid()).isFalse(); + assertThat(polling.getNameOf(first)).isNotBlank(); + } + + @Test + void aContextWithoutAListenerRunsNoPollThreadAndTakesPollingCallsSafely() { + try (Context plain = new Context()) { + plain.pauseAvailabilityPolling(); + plain.resumeAvailabilityPolling(); + assertThat(plain.getBackendsCount()).isPositive(); + } + } + + @Test + void pollingControlsRejectAClosedContext() { + Context closed = new Context(); + closed.close(); + assertThatThrownBy(closed::pauseAvailabilityPolling).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(closed::resumeAvailabilityPolling).isInstanceOf(IllegalStateException.class); + } + + @Test + void autoPowerManagementSupportIsQueryable() { + assertThat(Context.isAutoPowerManagementSupported()).isIn(true, false); + } + + @Test + void noSpuriousEventsWithoutAnActualTransition() throws Exception { + int before = EVENTS.size(); + Thread.sleep(2500); + assertThat(EVENTS.subList(Math.min(before, EVENTS.size()), EVENTS.size())) + .as("a baseline scan must not fabricate transitions") + .isEmpty(); + } +} diff --git a/src/test/java/org/mcaccess/prism/PrismErrorTest.java b/src/test/java/org/mcaccess/prism/PrismErrorTest.java new file mode 100644 index 0000000..a606b18 --- /dev/null +++ b/src/test/java/org/mcaccess/prism/PrismErrorTest.java @@ -0,0 +1,53 @@ +package org.mcaccess.prism; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PrismErrorTest { + + @Test + void codeMatchesOrdinal() { + for (PrismError error : PrismError.values()) { + assertThat(error.getCode()) + .as("%s must keep code == ordinal, since fromCode indexes by code", error) + .isEqualTo(error.ordinal()); + } + } + + @Test + void fromCodeRoundTripsEveryConstant() { + for (PrismError error : PrismError.values()) { + assertThat(PrismError.fromCode(error.getCode())).isSameAs(error); + } + } + + @Test + void fromCodeClampsUnrecognisedCodes() { + assertThat(PrismError.fromCode(-1)).isSameAs(PrismError.UNKNOWN); + assertThat(PrismError.fromCode(PrismError.values().length)).isSameAs(PrismError.UNKNOWN); + assertThat(PrismError.fromCode(9999)).isSameAs(PrismError.UNKNOWN); + } + + @Test + void everyErrorProducesADistinctExceptionType() { + for (PrismError error : PrismError.values()) { + if (error.isSuccess()) { + continue; + } + PrismException thrown = error.newException("boom"); + assertThat(thrown.getMessage()).isEqualTo("boom"); + assertThat(thrown.getClass()) + .as("%s must map to its own exception subtype", error) + .isNotSameAs(PrismException.class); + } + } + + @Test + void okIsNotAnError() { + assertThat(PrismError.OK.isSuccess()).isTrue(); + assertThatThrownBy(() -> PrismError.OK.newException("boom")) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/org/mcaccess/prism/PrismLogTest.java b/src/test/java/org/mcaccess/prism/PrismLogTest.java new file mode 100644 index 0000000..484a823 --- /dev/null +++ b/src/test/java/org/mcaccess/prism/PrismLogTest.java @@ -0,0 +1,115 @@ +package org.mcaccess.prism; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PrismLogTest { + + @AfterEach + void detach() { + PrismLog.clearListener(); + PrismLog.setLevel(PrismLog.Level.NONE); + } + + @Test + void levelCodesMatchOrdinals() { + for (PrismLog.Level level : PrismLog.Level.values()) { + assertThat(level.getCode()).isEqualTo(level.ordinal()); + } + } + + @Test + void unrecognisedLevelDegradesToNone() { + assertThat(PrismLog.Level.fromCode(-1)).isSameAs(PrismLog.Level.NONE); + assertThat(PrismLog.Level.fromCode(99)).isSameAs(PrismLog.Level.NONE); + for (PrismLog.Level level : PrismLog.Level.values()) { + assertThat(PrismLog.Level.fromCode(level.getCode())).isSameAs(level); + } + } + + @Test + void setLevelReturnsThePreviousLevel() { + PrismLog.setLevel(PrismLog.Level.WARN); + assertThat(PrismLog.setLevel(PrismLog.Level.DEBUG)).isSameAs(PrismLog.Level.WARN); + assertThat(PrismLog.setLevel(PrismLog.Level.NONE)).isSameAs(PrismLog.Level.DEBUG); + } + + @Test + void messagesRoundTripThroughTheListener() throws Exception { + CountDownLatch seen = new CountDownLatch(1); + List captured = new CopyOnWriteArrayList<>(); + PrismLog.setListener((level, source, message) -> { + if ("junit".equals(source)) { + captured.add(level + "|" + source + "|" + message); + seen.countDown(); + } + }); + PrismLog.setLevel(PrismLog.Level.TRACE); + + PrismLog.log(PrismLog.Level.WARN, "junit", "hello from the test"); + PrismLog.flush(); + + assertThat(seen.await(5, TimeUnit.SECONDS)).as("listener must receive the message").isTrue(); + assertThat(captured).contains("WARN|junit|hello from the test"); + } + + @Test + void clearListenerStopsDelivery() throws Exception { + CountDownLatch first = new CountDownLatch(1); + PrismLog.setListener((level, source, message) -> { + if ("junit".equals(source)) { + first.countDown(); + } + }); + PrismLog.setLevel(PrismLog.Level.TRACE); + PrismLog.log(PrismLog.Level.INFO, "junit", "before"); + PrismLog.flush(); + assertThat(first.await(5, TimeUnit.SECONDS)).isTrue(); + + List afterClear = new CopyOnWriteArrayList<>(); + PrismLog.clearListener(); + PrismLog.log(PrismLog.Level.INFO, "junit", "after"); + PrismLog.flush(); + Thread.sleep(200); + assertThat(afterClear).isEmpty(); + } + + @Test + void aThrowingListenerDoesNotEscapeIntoPrism() throws Exception { + CountDownLatch called = new CountDownLatch(1); + Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((t, e) -> { }); + try { + PrismLog.setListener((level, source, message) -> { + called.countDown(); + throw new IllegalStateException("listener blew up"); + }); + PrismLog.setLevel(PrismLog.Level.TRACE); + PrismLog.log(PrismLog.Level.ERROR, "junit", "boom"); + PrismLog.flush(); + assertThat(called.await(5, TimeUnit.SECONDS)).isTrue(); + + // PRISM's logging thread must still be alive and delivering. + PrismLog.log(PrismLog.Level.INFO, "junit", "still here"); + PrismLog.flush(); + } finally { + Thread.setDefaultUncaughtExceptionHandler(previous); + } + } + + @Test + void nullArgumentsAreRejected() { + assertThatThrownBy(() -> PrismLog.setLevel(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> PrismLog.log(null, "s", "m")).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> PrismLog.log(PrismLog.Level.INFO, null, "m")).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> PrismLog.log(PrismLog.Level.INFO, "s", null)).isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/org/mcaccess/prism/PrismTest.java b/src/test/java/org/mcaccess/prism/PrismTest.java index 3744f84..67548c6 100644 --- a/src/test/java/org/mcaccess/prism/PrismTest.java +++ b/src/test/java/org/mcaccess/prism/PrismTest.java @@ -52,22 +52,21 @@ void testBackendAcquireAndCreate() { BackendId id = ctx.getIdOf(i); try (Backend backend = ctx.create(id)) { assertThat(backend.getName()).isNotBlank(); - BackendFeatures features = backend.getFeatures(); - assertThat(features).isNotNull(); - - if (features.supportsGetVolume()) { + long features = backend.getFeatures(); + + if (BackendFeature.SUPPORTS_GET_VOLUME.isSupportedBy(features)) { float volume = backend.getVolume(); assertThat(volume).isBetween(0.0f, 1.0f); } - if (features.supportsCountVoices()) { + if (BackendFeature.SUPPORTS_COUNT_VOICES.isSupportedBy(features)) { int voicesCount = backend.getVoicesCount(); assertThat(voicesCount).isGreaterThanOrEqualTo(0); - if (voicesCount > 0 && features.supportsGetVoiceName()) { + if (voicesCount > 0 && BackendFeature.SUPPORTS_GET_VOICE_NAME.isSupportedBy(features)) { String voiceName = backend.getVoiceName(0); assertThat(voiceName).isNotNull(); } - if (voicesCount > 0 && features.supportsGetVoiceLanguage()) { + if (voicesCount > 0 && BackendFeature.SUPPORTS_GET_VOICE_LANGUAGE.isSupportedBy(features)) { String voiceLang = backend.getVoiceLanguage(0); assertThat(voiceLang).isNotNull(); } @@ -94,8 +93,8 @@ void testSpeakToMemoryIfSupported() { for (int i = 0; i < ctx.getBackendsCount(); i++) { BackendId id = ctx.getIdOf(i); try (Backend backend = ctx.create(id)) { - BackendFeatures features = backend.getFeatures(); - if (features.supportsSpeakToMemory()) { + long features = backend.getFeatures(); + if (BackendFeature.SUPPORTS_SPEAK_TO_MEMORY.isSupportedBy(features)) { AtomicBoolean received = new AtomicBoolean(false); AtomicInteger totalSamples = new AtomicInteger(0); diff --git a/src/test/java/org/mcaccess/prism/RegistryInfoTest.java b/src/test/java/org/mcaccess/prism/RegistryInfoTest.java new file mode 100644 index 0000000..870de2d --- /dev/null +++ b/src/test/java/org/mcaccess/prism/RegistryInfoTest.java @@ -0,0 +1,59 @@ +package org.mcaccess.prism; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RegistryInfoTest { + + @Test + void listsEveryRegisteredBackendWithNameAndPriority() { + try (Context ctx = new Context()) { + List backends = ctx.getRegisteredBackends(); + assertThat(backends).hasSize(ctx.getBackendsCount()); + assertThat(backends).allSatisfy(info -> { + assertThat(info.name()).isNotBlank(); + assertThat(info.id().isInvalid()).isFalse(); + }); + assertThat(backends).extracting(BackendInfo::name).doesNotHaveDuplicates(); + assertThat(backends).extracting(BackendInfo::id).doesNotHaveDuplicates(); + } + } + + @Test + void entriesAgreeWithTheIndividualLookups() { + try (Context ctx = new Context()) { + for (BackendInfo info : ctx.getRegisteredBackends()) { + assertThat(ctx.getNameOf(info.id())).isEqualTo(info.name()); + assertThat(ctx.getPriorityOf(info.id())).isEqualTo(info.priority()); + assertThat(ctx.getIdOf(info.name())).isEqualTo(info.id()); + assertThat(ctx.exists(info.id())).isTrue(); + } + } + } + + @Test + void theListIsImmutable() { + try (Context ctx = new Context()) { + List backends = ctx.getRegisteredBackends(); + assertThatThrownBy(() -> backends.add(new BackendInfo(BackendId.NVDA, "x", 1))) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Test + void aClosedContextRejectsEnumeration() { + Context ctx = new Context(); + ctx.close(); + assertThatThrownBy(ctx::getRegisteredBackends).isInstanceOf(IllegalStateException.class); + } + + @Test + void loadFailureIsNullWhenTheLibraryLoaded() { + assertThat(Prism.isAvailable()).isTrue(); + assertThat(Prism.getLoadFailure()).isNull(); + } +}