diff --git a/CHANGELOG.md b/CHANGELOG.md index 369ce26d..bcb1e29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,14 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [0.0.5-beta.1] - 2026-10-05 -第五个测试版:按键震动更响更清脆并可调强度,按键音效恢复,“中/英”键移到空格右边,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 - -- 按键震动更响更清脆:不再走系统给键盘的固定低振幅,直接驱动振动马达播放满强度的短促点击(Android 11 起用 CLICK 原语,Android 10 用系统预设点击,更早版本用 20ms 满幅脉冲),仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。 +第五个测试版:按键震动更清脆、可选手感和强度,按键音效恢复并内置多种音效,“中/英”键移到空格右边,九键单按一个键时拼音栏显示字母,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 + +- 按键震动不再走系统给键盘的固定低振幅,直接驱动振动马达,仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。 +- 新增“震动手感”:清脆(默认,最短促的 TICK)、有力(CLICK)、系统(交给手机厂商调好的键盘震动)。线性马达上长脉冲会“嗡”出余震,不支持原语的机型改用 8–12ms 短脉冲。 +- 新增内置按键音效:系统、清脆、机械、木质、打字机、气泡,点选即试听。音效由脚本合成(`scripts/generate_key_sounds.py`),音量跟随系统音量。 +- 九键单按 4、5、7、8、9 时,拼音栏显示首个候选的声母(如 4 → h),不再停在数字;左侧拼音列同时高亮该字母。 +- 修复九键打第一个字时单按 4、5、7、8、9,首个候选字只显示一半:候选格沿用了先显示的数字的宽度,现在会按新候选重新排版。 +- “关于 → 诊断”显示本机振动马达支持的能力,便于排查震感问题。 - 新增“震动强度”滑块(10%–100%,默认 100%),在偏好设置“触感震动”下方和键盘内设置首屏;拖动时实时试震,随用户数据导出、导入。 - 修复“按键音效”打开后没有声音:系统“触摸提示音”关闭时按键音也被一起静音,现在只看 App 内的开关。 - 26 键的“中/英”键移到空格右边,与“,。”键对调,中英文键盘一致。 diff --git a/app/src/main/java/llc/slacker/openime/AboutActivity.kt b/app/src/main/java/llc/slacker/openime/AboutActivity.kt index 1c392834..fb19ea85 100644 --- a/app/src/main/java/llc/slacker/openime/AboutActivity.kt +++ b/app/src/main/java/llc/slacker/openime/AboutActivity.kt @@ -97,7 +97,8 @@ class AboutActivity : Activity() { safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") else -> "没有异常记录。" - } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", + } + "\n" + llc.slacker.openime.keyboard.KeyHaptics.describe(this) + + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", iconRes = R.drawable.ic_pref_info, ) if (lastReport == null && !safeMode) return card diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 0e3d47c0..869a2b89 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -203,6 +203,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { override fun onHapticStrengthChanged(percent: Int) { refreshLiveIme() } + override fun onFeedbackStyleChanged() { + refreshLiveIme() + } override fun onKeyboardHeightChanged(percent: Int) { ImeSettingsRepository.saveKeyboardHeightPercent(this, percent) refreshLiveIme() diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index 90f49cc3..e5d70ba4 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -33,7 +33,7 @@ interface CandidateResolver { lockPreferred: Boolean = false, ): CandidatePipeline.NineKeyResolution - /** Syllables of [candidate] if it spells exactly all of [digits]; null otherwise. */ + /** Syllables of [candidate] if it spells exactly all of [digits] (a lone key may be its initial); null otherwise. */ fun nineKeyReadingFor(digits: String, candidate: String): List? = null /** Readings of [digits] for the left rail, best first (see [NineKeyReading]). */ diff --git a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index 61b16c65..4ba03819 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -257,7 +257,18 @@ internal class NineKeyLocalDecoder( } return false } - return if (walk(0, 0)) picked.filterNotNull() else null + if (walk(0, 0)) return picked.filterNotNull() + // One key and one character: the key can only be the character's + // initial (4 -> 和 shows "h", like the rail). Without this the pre-edit + // stayed on the bare digit for keys whose letters are all initials + // (4, 5, 7, 8, 9) while 2 / 3 / 6 showed a / e / o. + if (bounded.length == 1 && chars.size == 1) { + val initial = readingsByChar[chars[0]].orEmpty() + .map { it.take(1) } + .firstOrNull { digitsForPinyin(it) == bounded } + if (initial != null) return listOf(initial) + } + return null } /** diff --git a/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt b/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt new file mode 100644 index 00000000..1901eb7f --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime.data + +import llc.slacker.openime.R + +/** How a key press feels. Labels are what the 震动手感 setting shows. */ +enum class HapticStyle(val key: String, val label: String) { + /** The shortest, sharpest pulse: PRIMITIVE_TICK / EFFECT_TICK / an 8 ms tap. */ + CRISP("crisp", "清脆"), + + /** A fuller click: PRIMITIVE_CLICK / EFFECT_CLICK / a 12 ms tap. */ + FIRM("firm", "有力"), + + /** Whatever the phone maker tuned for keyboards (KEYBOARD_TAP); strength does not apply. */ + SYSTEM("system", "系统"), + ; + + companion object { + fun fromKey(key: String?): HapticStyle = entries.firstOrNull { it.key == key } ?: CRISP + } +} + +/** + * The built-in key-click sounds. [SYSTEM] is the platform keypress sound; the + * rest are short clicks synthesized by scripts/generate_key_sounds.py, so they + * carry no third-party rights. + */ +enum class KeySoundStyle(val key: String, val label: String, val rawRes: Int) { + SYSTEM("system", "系统", 0), + CRISP("crisp", "清脆", R.raw.key_sound_crisp), + MECHANICAL("mechanical", "机械", R.raw.key_sound_mechanical), + WOOD("wood", "木质", R.raw.key_sound_wood), + TYPEWRITER("typewriter", "打字机", R.raw.key_sound_typewriter), + BUBBLE("bubble", "气泡", R.raw.key_sound_bubble), + ; + + companion object { + fun fromKey(key: String?): KeySoundStyle = entries.firstOrNull { it.key == key } ?: SYSTEM + } +} diff --git a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt index a443efdb..68b02e9f 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -14,6 +14,8 @@ object ImeSettingsRepository { private const val KEY_SOUND = "sound" private const val KEY_HAPTIC = "haptic" private const val KEY_HAPTIC_STRENGTH = "haptic_strength_percent" + private const val KEY_HAPTIC_STYLE = "haptic_style" + private const val KEY_SOUND_STYLE = "key_sound_style" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" @@ -138,6 +140,26 @@ object ImeSettingsRepository { .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(10, 100)).apply() } + /** 震动手感 key: "crisp" (default), "firm" or "system". */ + fun loadHapticStyle(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_HAPTIC_STYLE, "crisp") ?: "crisp" + + fun saveHapticStyle(context: Context, key: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_HAPTIC_STYLE, key).apply() + } + + /** 按键音效 sound key: "system" (default) or one of the bundled clicks. */ + fun loadKeySoundStyle(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_SOUND_STYLE, "system") ?: "system" + + fun saveKeySoundStyle(context: Context, key: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_SOUND_STYLE, key).apply() + } + fun loadSwipeUpDigits(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_SWIPE_UP_DIGITS, true) diff --git a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt index 77ef2ede..8d863a76 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -43,6 +43,8 @@ internal data class ArchiveSettings( val voiceStripFillers: Boolean = true, val voicePunctuationAsSpace: Boolean = false, val hapticStrengthPercent: Int = 100, + val hapticStyle: String = "crisp", + val keySoundStyle: String = "system", ) internal data class RimeUserDictionaryArchive( @@ -176,6 +178,8 @@ internal object UserDataArchiveCodec { .put("sound", value.sound) .put("haptic", value.haptic) .put("haptic_strength_percent", value.hapticStrengthPercent) + .put("haptic_style", value.hapticStyle) + .put("key_sound_style", value.keySoundStyle) .put("popup", value.popup) .put("fuzzy", value.fuzzy) .put("swipe_up_digits", value.swipeUpDigits) @@ -195,6 +199,8 @@ internal object UserDataArchiveCodec { sound = value.optBoolean("sound", true), haptic = value.optBoolean("haptic", true), hapticStrengthPercent = value.optInt("haptic_strength_percent", 100).coerceIn(10, 100), + hapticStyle = value.optString("haptic_style", "crisp"), + keySoundStyle = value.optString("key_sound_style", "system"), popup = value.optBoolean("popup", false), fuzzy = value.optBoolean("fuzzy", false), swipeUpDigits = value.optBoolean("swipe_up_digits", true), @@ -325,6 +331,8 @@ internal object UserDataRepository { sound = ImeSettingsRepository.loadSound(context), haptic = ImeSettingsRepository.loadHaptic(context), hapticStrengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context), + hapticStyle = ImeSettingsRepository.loadHapticStyle(context), + keySoundStyle = ImeSettingsRepository.loadKeySoundStyle(context), popup = ImeSettingsRepository.loadPopup(context), fuzzy = ImeSettingsRepository.loadFuzzy(context), swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), @@ -408,6 +416,8 @@ internal object UserDataRepository { ImeSettingsRepository.saveSound(context, value.sound) ImeSettingsRepository.saveHaptic(context, value.haptic) ImeSettingsRepository.saveHapticStrengthPercent(context, value.hapticStrengthPercent) + ImeSettingsRepository.saveHapticStyle(context, value.hapticStyle) + ImeSettingsRepository.saveKeySoundStyle(context, value.keySoundStyle) ImeSettingsRepository.savePopup(context, value.popup) ImeSettingsRepository.saveFuzzy(context, value.fuzzy) ImeSettingsRepository.saveSwipeUpDigits(context, value.swipeUpDigits) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt index c35e6a6e..af7cd1d7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -344,8 +344,11 @@ internal class CandidateBarController( isClickable = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, + // WRAP_CONTENT, not MATCH_PARENT: items are reused across + // queries, and a TextView with a non-wrap width keeps its old + // width on setText, clipping a wider new candidate. LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ), ) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index af16741e..05b44c1f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.StateListDrawable import android.inputmethodservice.InputMethodService -import android.media.AudioManager import android.os.Build import android.os.Handler import android.os.Looper @@ -14,7 +13,6 @@ import android.text.TextUtils import android.util.Log import android.view.Gravity import android.view.MotionEvent -import android.view.SoundEffectConstants import android.view.View import android.view.ViewGroup import android.view.WindowInsets @@ -34,7 +32,9 @@ import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.editor.EditorInfoAdapter @@ -123,6 +123,9 @@ open class ImeKeyboardView( fun onFuzzyChanged(enabled: Boolean) fun onKeyboardHeightChanged(percent: Int) {} fun onHapticStrengthChanged(percent: Int) {} + + /** 震动手感 or 按键音效 changed; the new value is already saved. */ + fun onFeedbackStyleChanged() {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} fun onOpenAbout() {} fun onOpenDataManagement() {} @@ -144,8 +147,8 @@ open class ImeKeyboardView( /** Slider drags preview the strength at most this often. */ private const val STRENGTH_PREVIEW_INTERVAL_MS = 80L - /** playSoundEffect's "use the default UI click volume" value. */ - private const val KEY_CLICK_DEFAULT_VOLUME = -1f + /** Long enough for SoundPool to decode a just-selected click. */ + private const val SOUND_PREVIEW_DELAY_MS = 120L } @@ -344,10 +347,11 @@ open class ImeKeyboardView( private var hapticEnabled = ImeSettingsRepository.loadHaptic(context) private val keyHaptics = KeyHaptics(context).apply { strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) + style = HapticStyle.fromKey(ImeSettingsRepository.loadHapticStyle(context)) } private var lastStrengthPreviewMs = 0L - private val audioManager by lazy { - context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private val keySounds = KeySounds(context).apply { + style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) } private var popupEnabled = ImeSettingsRepository.loadPopup(context) private var fuzzyEnabled = ImeSettingsRepository.loadFuzzy(context) @@ -629,6 +633,8 @@ open class ImeKeyboardView( currentSound = { soundEnabled }, currentHaptic = { hapticEnabled }, currentHapticStrengthPercent = { keyHaptics.strengthPercent }, + currentHapticStyle = { keyHaptics.style }, + currentKeySoundStyle = { keySounds.style }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentExtraToggle = ::onState, @@ -644,6 +650,8 @@ open class ImeKeyboardView( onToggleChanged = ::updateSettingToggle, onKeyboardHeightChanged = ::setKeyboardHeightPercent, onHapticStrengthChanged = ::setHapticStrengthPercent, + onHapticStyleChanged = ::setHapticStyle, + onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, onOpenAbout = listener::onOpenAbout, @@ -1573,6 +1581,23 @@ open class ImeKeyboardView( } } + /** Save the 震动手感 choice and let it be felt once. */ + private fun setHapticStyle(next: HapticStyle) { + keyHaptics.style = next + ImeSettingsRepository.saveHapticStyle(context, next.key) + listener.onFeedbackStyleChanged() + keyHaptics.click(this) + } + + /** Save the 按键音效 choice and play it once, even while key sounds are off. */ + private fun setKeySoundStyle(next: KeySoundStyle) { + keySounds.style = next + ImeSettingsRepository.saveKeySoundStyle(context, next.key) + listener.onFeedbackStyleChanged() + // A freshly loaded sample decodes asynchronously; give it a moment. + postDelayed({ keySounds.play() }, SOUND_PREVIEW_DELAY_MS) + } + private fun setKeyboardHeightPercent(percent: Int) { val bounded = percent.coerceIn(80, 120) if (keyboardHeightPercent == bounded) return @@ -1622,6 +1647,8 @@ open class ImeKeyboardView( popupEnabled = popup fuzzyEnabled = fuzzy keyHaptics.strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) + keyHaptics.style = HapticStyle.fromKey(ImeSettingsRepository.loadHapticStyle(context)) + keySounds.style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) if (heightChanged) { keyboardHeightPercent = persistedHeight renderedMode = null @@ -1687,6 +1714,7 @@ open class ImeKeyboardView( } open fun shutdown() { + keySounds.release() // The field these chips belong to is going away. clearInlineSuggestions() if (panel == Panel.CLIPBOARD) clipboardPanelController.invalidatePendingLoad() @@ -2830,20 +2858,7 @@ open class ImeKeyboardView( protected fun feedback() { if (hapticEnabled) keyHaptics.click(this) - if (soundEnabled) { - // playSoundEffect(effect) plays only when the system "touch sounds" - // setting is on, which most phones ship off, so the in-app switch - // did nothing. The volume overload skips that check (AOSP LatinIME - // does the same); -1 is the platform's default key-click volume. - runCatching { - if (audioManager != null) { - audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, KEY_CLICK_DEFAULT_VOLUME) - } else { - isSoundEffectsEnabled = true - playSoundEffect(SoundEffectConstants.CLICK) - } - } - } + if (soundEnabled) keySounds.play() } /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt index 739f2e46..14829711 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt @@ -9,9 +9,10 @@ import android.os.Vibrator import android.os.VibratorManager import android.view.HapticFeedbackConstants import android.view.View +import llc.slacker.openime.data.HapticStyle /** - * Key-press haptics: one short, full-strength click. + * Key-press haptics: one short click in the chosen [HapticStyle]. * * `performHapticFeedback(KEYBOARD_TAP)` is the platform default, but since * Android 14 QPR3 the system plays it as PRIMITIVE_CLICK at the device's fixed @@ -20,11 +21,14 @@ import android.view.View * HeliBoard's custom mode) drive the vibrator themselves instead. This does the * same with the crispest effect the actuator offers: * - * - API 30+ with primitive support: PRIMITIVE_CLICK, scaled by [strengthPercent]; - * - otherwise with amplitude control: a 20 ms one-shot at that share of full amplitude; - * - otherwise: API 29+ uses the actuator-tuned EFFECT_CLICK at full strength and a + * - API 30+ with primitive support: the style's primitive, scaled by [strengthPercent]; + * - otherwise with amplitude control: a short one-shot at that share of full amplitude; + * - otherwise: API 29+ uses the actuator-tuned predefined effect at full strength and a * shorter pulse below it (motors without amplitude control only vary by time). * + * Long pulses ring on linear motors such as the X-axis motor in Xiaomi phones, + * which feels muddy; the one-shot fallbacks are kept to 8–12 ms for that reason. + * * The 震动强度 slider sets [strengthPercent] (10–100, like Sogou's and Huawei * 小艺's strength settings); 100 is the full click. * @@ -50,11 +54,22 @@ internal class KeyHaptics(context: Context) { cachedClick = null } + var style: HapticStyle = HapticStyle.CRISP + set(value) { + if (field == value) return + field = value + cachedClick = null + } + private var cachedClick: VibrationEffect? = null private val click: VibrationEffect? get() = cachedClick ?: buildClick().also { cachedClick = it } fun click(view: View) { + if (style == HapticStyle.SYSTEM) { + view.performHapticFeedback(FALLBACK_CONSTANT) + return + } val target = vibrator val effect = click if (target == null || effect == null || !target.hasVibrator()) { @@ -74,29 +89,56 @@ internal class KeyHaptics(context: Context) { private fun buildClick(): VibrationEffect? { val target = vibrator ?: return null val share = strengthPercent / 100f - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - target.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_CLICK) - ) { - return VibrationEffect.startComposition() - .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, share) - .compose() + val crisp = style == HapticStyle.CRISP + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val primitive = + if (crisp) VibrationEffect.Composition.PRIMITIVE_TICK else VibrationEffect.Composition.PRIMITIVE_CLICK + if (target.areAllPrimitivesSupported(primitive)) { + return VibrationEffect.startComposition().addPrimitive(primitive, share).compose() + } } + val pulseMs = if (crisp) CRISP_PULSE_MS else FIRM_PULSE_MS if (target.hasAmplitudeControl()) { - return VibrationEffect.createOneShot(LEGACY_CLICK_MS, (255 * share).toInt().coerceIn(1, 255)) + return VibrationEffect.createOneShot(pulseMs, (255 * share).toInt().coerceIn(1, 255)) } if (strengthPercent == MAX_STRENGTH && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK) + return VibrationEffect.createPredefined(if (crisp) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK) } - val durationMs = (MIN_PULSE_MS + (LEGACY_CLICK_MS - MIN_PULSE_MS) * share).toLong() + val durationMs = (MIN_PULSE_MS + (pulseMs - MIN_PULSE_MS) * share).toLong() return VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE) } companion object { const val MIN_STRENGTH = 10 const val MAX_STRENGTH = 100 - private const val LEGACY_CLICK_MS = 20L - private const val MIN_PULSE_MS = 6L + private const val CRISP_PULSE_MS = 8L + private const val FIRM_PULSE_MS = 12L + private const val MIN_PULSE_MS = 4L + + /** One line for 关于 → 诊断: what this phone's vibrator can do. */ + fun describe(context: Context): String { + val vibrator: Vibrator? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + if (vibrator == null || !vibrator.hasVibrator()) return "振动马达:未检测到" + val parts = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val tick = vibrator.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_TICK) + val click = vibrator.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_CLICK) + parts += when { + tick && click -> "支持 TICK/CLICK 原语" + click -> "支持 CLICK 原语" + tick -> "支持 TICK 原语" + else -> "不支持原语" + } + } + parts += if (vibrator.hasAmplitudeControl()) "可调振幅" else "不可调振幅" + return "振动马达:" + parts.joinToString(",") + "(" + Build.MANUFACTURER + " " + Build.MODEL + ")" + } /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */ private val FALLBACK_CONSTANT = diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt new file mode 100644 index 00000000..57b72500 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt @@ -0,0 +1,77 @@ +package llc.slacker.openime.keyboard + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import llc.slacker.openime.data.KeySoundStyle + +/** + * Plays one key click in the chosen [style]. Bundled clicks go through a small + * [SoundPool] tagged as UI sonification, so their loudness follows the system + * sound volume. The system click uses the volume overload of + * playSoundEffect, which plays even when the system "touch sounds" setting is + * off; the in-app switch alone decides whether keys click. + */ +internal class KeySounds(context: Context) { + private val appContext = context.applicationContext + private val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private var pool: SoundPool? = null + private val loaded = HashMap() + private val ready = HashSet() + + var style: KeySoundStyle = KeySoundStyle.SYSTEM + set(value) { + field = value + if (value != KeySoundStyle.SYSTEM) ensureLoaded(value) + } + + fun play() { + val current = style + if (current == KeySoundStyle.SYSTEM) { + runCatching { audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, DEFAULT_VOLUME) } + return + } + val sample = ensureLoaded(current) ?: return + // A click requested before its sample finished decoding is dropped, + // not queued: a late click would sound like lag. + if (sample in ready) pool?.play(sample, 1f, 1f, 1, 0, 1f) + } + + fun release() { + pool?.release() + pool = null + loaded.clear() + ready.clear() + } + + private fun ensureLoaded(target: KeySoundStyle): Int? { + loaded[target]?.let { return it } + val soundPool = pool ?: SoundPool.Builder() + .setMaxStreams(MAX_STREAMS) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .build() + .also { created -> + created.setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) ready += sampleId + } + pool = created + } + return runCatching { soundPool.load(appContext, target.rawRes, 1) } + .getOrNull() + ?.also { loaded[target] = it } + } + + private companion object { + /** Fast typing overlaps a few clicks; more would only pile up noise. */ + const val MAX_STREAMS = 4 + + /** playSoundEffect's "use the default UI click volume" value. */ + const val DEFAULT_VOLUME = -1f + } +} diff --git a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt index 1ea069ef..86aadca0 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -16,6 +16,8 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.ImeSettingsActivity import llc.slacker.openime.R +import llc.slacker.openime.data.HapticStyle +import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeMotionTokens @@ -51,6 +53,8 @@ internal class SettingsPanelController( private val currentSound: () -> Boolean, private val currentHaptic: () -> Boolean, private val currentHapticStrengthPercent: () -> Int, + private val currentHapticStyle: () -> HapticStyle, + private val currentKeySoundStyle: () -> KeySoundStyle, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, /** State of the toggles added after the first four, looked up by their label. */ @@ -64,6 +68,8 @@ internal class SettingsPanelController( private val onToggleChanged: (String, Boolean) -> Unit, private val onKeyboardHeightChanged: (Int) -> Unit, private val onHapticStrengthChanged: (Int) -> Unit, + private val onHapticStyleChanged: (HapticStyle) -> Unit, + private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, private val onOpenAbout: () -> Unit, @@ -173,8 +179,10 @@ internal class SettingsPanelController( ) content.addSection("按键与输入") content.addCard( - toggleRow("按键音效", "机械轴敲击反馈", R.drawable.ic_pref_sound), + toggleRow("按键音效", "按键时播放提示音", R.drawable.ic_pref_sound), + soundStyleRow(R.drawable.ic_pref_sound), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), + hapticStyleRow(R.drawable.ic_pref_haptic), sliderRow("震动强度", R.drawable.ic_pref_haptic, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), @@ -213,6 +221,11 @@ internal class SettingsPanelController( sliderRow("震动强度", 0, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), topMarginDp = ImeSpacingTokens.MD_DP, ) + content.addSection("按键反馈") + content.addCard( + soundStyleRow(0), + hapticStyleRow(0), + ) content.addSection("浮动键盘") content.addCard( sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { @@ -490,6 +503,83 @@ internal class SettingsPanelController( ) } + private fun soundStyleRow(iconRes: Int): View = + choiceRow("音效", iconRes, KeySoundStyle.entries.map { it.label }, currentKeySoundStyle().label) { label -> + onKeySoundStyleChanged(KeySoundStyle.entries.first { it.label == label }) + } + + private fun hapticStyleRow(iconRes: Int): View = + choiceRow("震动手感", iconRes, HapticStyle.entries.map { it.label }, currentHapticStyle().label) { label -> + onHapticStyleChanged(HapticStyle.entries.first { it.label == label }) + } + + /** + * A label with a scrollable row of options under it. Choosing one saves it, + * previews it (the callback plays the sound or vibration) and redraws the + * row so the selection moves; it never plays the old feedback first. + */ + private fun choiceRow( + label: String, + iconRes: Int, + options: List, + selected: String, + onChoose: (String) -> Unit, + ): View { + val chips = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + options.forEachIndexed { index, option -> + val active = option == selected + addView( + TextView(context).apply { + text = option + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER + includeFontPadding = false + minWidth = toPx(56) + setPadding(toPx(14), 0, toPx(14), 0) + tag = if (active) "tab-active" else "panel-tab" + typeface = android.graphics.Typeface.create( + "sans-serif-medium", + if (active) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL, + ) + contentDescription = "$label:$option,${if (active) "已选中" else "未选中"}" + if (Build.VERSION.SDK_INT >= 30) stateDescription = if (active) "已选中" else "未选中" + isClickable = true + isFocusable = true + setOnClickListener { + onChoose(option) + renderSettings(reusePanel = true) + } + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(40)).apply { + if (index > 0) marginStart = toPx(6) + }, + ) + } + } + val scroller = HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + addView(chips) + } + val row = rowShell(iconRes, alignTop = true) + row.addView( + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (iconRes != 0) 6 else 8) }) + addView( + scroller, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { + topMargin = toPx(ImeSpacingTokens.XS_DP) + }, + ) + }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), + ) + return row + } + /** * Appearance picker. On the preferences page the options sit under the * label, full width; in the keyboard they share the label's line. diff --git a/app/src/main/res/raw/key_sound_bubble.wav b/app/src/main/res/raw/key_sound_bubble.wav new file mode 100644 index 00000000..7d72f522 Binary files /dev/null and b/app/src/main/res/raw/key_sound_bubble.wav differ diff --git a/app/src/main/res/raw/key_sound_crisp.wav b/app/src/main/res/raw/key_sound_crisp.wav new file mode 100644 index 00000000..45589974 Binary files /dev/null and b/app/src/main/res/raw/key_sound_crisp.wav differ diff --git a/app/src/main/res/raw/key_sound_mechanical.wav b/app/src/main/res/raw/key_sound_mechanical.wav new file mode 100644 index 00000000..8787468c Binary files /dev/null and b/app/src/main/res/raw/key_sound_mechanical.wav differ diff --git a/app/src/main/res/raw/key_sound_typewriter.wav b/app/src/main/res/raw/key_sound_typewriter.wav new file mode 100644 index 00000000..a0a67fd8 Binary files /dev/null and b/app/src/main/res/raw/key_sound_typewriter.wav differ diff --git a/app/src/main/res/raw/key_sound_wood.wav b/app/src/main/res/raw/key_sound_wood.wav new file mode 100644 index 00000000..43b39735 Binary files /dev/null and b/app/src/main/res/raw/key_sound_wood.wav differ diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 77110022..8853fd7c 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -92,6 +92,22 @@ class NineKeyChineseTest { assertEquals("nihao", decoder.resolve("64426", preferredSuffix = null, fuzzy = false).previewSuffix) } + @Test + fun aLoneKeyAlignsToTheTopCandidatesInitial() { + // 4 / 5 / 7 / 8 / 9 hold only initials; the pre-edit shows the top + // candidate's initial instead of staying on the digit. + assertEquals(listOf("h"), pipeline.nineKeyReadingFor("4", "和")) + assertEquals(listOf("l"), pipeline.nineKeyReadingFor("5", "了")) + assertEquals(listOf("s"), pipeline.nineKeyReadingFor("7", "是")) + assertEquals(listOf("t"), pipeline.nineKeyReadingFor("8", "他")) + assertEquals(listOf("w"), pipeline.nineKeyReadingFor("9", "我")) + // A full syllable still wins, and a character the key cannot start stays unaligned. + assertEquals(listOf("a"), pipeline.nineKeyReadingFor("2", "啊")) + assertEquals(null, pipeline.nineKeyReadingFor("4", "啊")) + // More than one key keeps requiring complete syllables. + assertEquals(null, pipeline.nineKeyReadingFor("44", "和")) + } + private fun resolve(digits: String): CandidatePipeline.NineKeyResolution = pipeline.resolveNineKey( digits = digits, diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index 20bcfbc1..fb56eed3 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -92,10 +92,11 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 ## 按键触感 - Android 14 QPR3 起,`performHapticFeedback(KEYBOARD_TAP)` 由系统按设备配置的固定键盘振幅播放,多数机型偏弱;Gboard 也因此失去了自己的强度滑块。搜狗、小艺、HeliBoard 等都自己驱动振动马达。 -- 本项目由 `KeyHaptics` 直接调用 `Vibrator`:Android 11 起若支持则播放 `PRIMITIVE_CLICK`(强度 1.0),Android 10 用 `EFFECT_CLICK`,Android 8–9 用 20ms 满幅脉冲;用途标记为触摸反馈,仍跟随系统“触摸反馈”强度。没有振动马达时回退 `KEYBOARD_TAP`。 +- 本项目由 `KeyHaptics` 直接调用 `Vibrator`,按“震动手感”选择波形:清脆(默认)用 `PRIMITIVE_TICK` / `EFFECT_TICK` / 8ms 脉冲,有力用 `PRIMITIVE_CLICK` / `EFFECT_CLICK` / 12ms 脉冲,系统用 `KEYBOARD_TAP`(厂商调校,不受强度影响)。用途标记为触摸反馈,仍跟随系统“触摸反馈”强度。线性马达(如小米 X 轴马达)上长脉冲会产生余震,所以不使用超过 12ms 的脉冲。没有振动马达时回退 `KEYBOARD_TAP`。 - “震动强度”滑块 10%–100%(默认 100%)缩放这一下点击:支持原语时按比例缩放 `PRIMITIVE_CLICK`,有振幅控制时缩放振幅,否则缩短脉冲时长;拖动滑杆时最多每 80ms 试震一次。偏好设置放在“触感震动”下方,键盘内设置放在首屏“键盘高度”下方。 - 按键按下、删除键手势到位、按住空格进入语音都用这一下点击;不使用 `LONG_PRESS`、`CLOCK_TICK` 或长时长振动,避免余震拖沓。依据 Android [Haptics design principles](https://developer.android.com/develop/ui/views/haptics/haptics-principles)。 -- 按键音用带音量参数的 `AudioManager.playSoundEffect(FX_KEYPRESS_STANDARD, -1)`:单参数版本在系统“触摸提示音”关闭时不出声。 +- 按键音效可选:系统(带音量参数的 `AudioManager.playSoundEffect(FX_KEYPRESS_STANDARD, -1)`,单参数版本在系统“触摸提示音”关闭时不出声)以及 `res/raw/key_sound_*.wav` 五种合成音(清脆、机械、木质、打字机、气泡,`SoundPool` 播放,用途为 UI 提示音)。选择后立即试听一次,不会先播旧的音效或震动。 +- 偏好设置“按键与输入”里依次是:按键音效、音效、触感震动、震动手感、震动强度;键盘内设置把“音效”“震动手感”放在“按键反馈”一组。 ## 验收与可重复工件 diff --git a/scripts/generate_key_sounds.py b/scripts/generate_key_sounds.py new file mode 100644 index 00000000..bf1f8688 --- /dev/null +++ b/scripts/generate_key_sounds.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Synthesize openIME's built-in key-click sounds. + +Every sound is generated here from sine partials and seeded noise, so the +files carry no third-party rights and can be regenerated byte for byte: + + python3 scripts/generate_key_sounds.py + +Writes 48 kHz mono 16-bit WAV files to app/src/main/res/raw/key_sound_*.wav. +Each click is under 80 ms and peaks at -2 dBFS; playback volume follows the +system sound level. +""" + +import math +import random +import struct +import wave +from pathlib import Path + +RATE = 48_000 +PEAK = 10 ** (-2 / 20) +OUT = Path(__file__).resolve().parent.parent / "app" / "src" / "main" / "res" / "raw" + + +def samples(duration_s): + return int(RATE * duration_s) + + +def noise(n, seed): + rnd = random.Random(seed) + raw = [rnd.uniform(-1.0, 1.0) for _ in range(n + 1)] + # First difference: tilts the noise toward the treble so it reads as a tick. + return [raw[i + 1] - raw[i] for i in range(n)] + + +def decay(t, tau): + return math.exp(-t / tau) + + +def finish(signal): + """Normalise to PEAK and fade the last 2 ms to silence (no end click).""" + top = max(abs(s) for s in signal) or 1.0 + scaled = [s * PEAK / top for s in signal] + fade = samples(0.002) + for i in range(fade): + scaled[-1 - i] *= i / fade + return scaled + + +def crisp(): + n = samples(0.028) + hiss = noise(n, 11) + out = [] + for i in range(n): + t = i / RATE + out.append( + 0.7 * hiss[i] * decay(t, 0.0012) + + 0.6 * math.sin(2 * math.pi * 4200 * t) * decay(t, 0.005) + ) + return finish(out) + + +def mechanical(): + n = samples(0.055) + hiss = noise(n, 23) + second = samples(0.012) + out = [] + for i in range(n): + t = i / RATE + press = ( + 0.8 * hiss[i] * decay(t, 0.0018) + + 0.5 * math.sin(2 * math.pi * 2400 * t) * decay(t, 0.004) + + 0.3 * math.sin(2 * math.pi * 180 * t) * decay(t, 0.008) + ) + bottom = 0.0 + if i >= second: + tb = (i - second) / RATE + bottom = ( + 0.45 * hiss[i - second] * decay(tb, 0.0015) + + 0.35 * math.sin(2 * math.pi * 1300 * tb) * decay(tb, 0.006) + ) + out.append(press + bottom) + return finish(out) + + +def wood(): + n = samples(0.06) + hiss = noise(n, 37) + out = [] + for i in range(n): + t = i / RATE + out.append( + 0.8 * math.sin(2 * math.pi * 950 * t) * decay(t, 0.011) + + 0.35 * math.sin(2 * math.pi * 2300 * t) * decay(t, 0.005) + + 0.3 * hiss[i] * decay(t, 0.0009) + ) + return finish(out) + + +def typewriter(): + n = samples(0.075) + hiss = noise(n, 41) + out = [] + for i in range(n): + t = i / RATE + ring = sum(math.sin(2 * math.pi * f * t) for f in (3150, 4730, 6210)) / 3 + out.append( + 0.9 * hiss[i] * decay(t, 0.0028) + + 0.3 * ring * decay(t, 0.016) + + 0.3 * math.sin(2 * math.pi * 220 * t) * decay(t, 0.01) + ) + return finish(out) + + +def bubble(): + duration = 0.045 + n = samples(duration) + phase = 0.0 + out = [] + for i in range(n): + t = i / RATE + freq = 500 + 900 * (t / duration) ** 0.6 + phase += 2 * math.pi * freq / RATE + envelope = math.sin(math.pi * t / duration) ** 1.5 * decay(t, 0.03) + out.append(math.sin(phase) * envelope) + return finish(out) + + +SOUNDS = { + "key_sound_crisp": crisp, + "key_sound_mechanical": mechanical, + "key_sound_wood": wood, + "key_sound_typewriter": typewriter, + "key_sound_bubble": bubble, +} + + +def write(name, signal): + path = OUT / f"{name}.wav" + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(RATE) + wav.writeframes(b"".join(struct.pack("