Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ When text is marked as another language, TalkBack switches the voice to that lan

When you turn a change off, Backtalk no longer splits the text or pauses around it. Text marked as the voice's own language and country, such as US English for a US English voice, isn't split either, whatever the settings, since the voice doesn't change. Backtalk compares languages by their codes only, so Serbian in Latin and in Cyrillic script counts as two dialects, and Norwegian marked as `no` and as `nb` counts as two languages.

### Speak numbers as digits

**Verbosity** > **Speak numbers as digits** asks a compatible speech engine to read consecutive digits separately, including leading zeros. It is off by default. It applies to numbers from 2 to 32 digits, including six-digit codes and twenty-digit electricity tokens. Runs of 33 or more digits and instructions supplied by an app are left alone.

This uses Android's pronunciation hints without changing the text. Some engines, including the tested Vocalizer Tessa voice, may ignore the hint and continue reading numbers normally. There is no automatic fallback to another reading method.

### How emoji are spoken

Some speech engines read emoji badly, use names of their own, or don't read them at all. **Verbosity** > **Emoji** chooses how they're spoken:
Expand Down
2 changes: 2 additions & 0 deletions lint.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
Keep MissingTranslation enabled globally and suppress it only for these exact resources.
-->
<issue id="MissingTranslation">
<ignore regexp="title_pref_speak_numbers_as_digits" />
<ignore regexp="summary_pref_speak_numbers_as_digits" />
<ignore regexp="voice_commands_help_call" />
<ignore regexp="voice_commands_help_call_description" />
<ignore regexp="voice_commands_help_call_control" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@
import com.google.android.accessibility.utils.output.FailoverTextToSpeech;
import com.google.android.accessibility.utils.output.FeedbackController;
import com.google.android.accessibility.utils.output.FeedbackProcessingUtils;
import com.google.android.accessibility.utils.output.NumbersAsDigits;
import com.google.android.accessibility.utils.output.ScrollActionRecord;
import com.google.android.accessibility.utils.output.SelectionStateReader;
import com.google.android.accessibility.utils.output.SpeechController;
Expand Down Expand Up @@ -3662,6 +3663,10 @@ protected void reloadPreferences(@Nullable String key) {
res,
R.string.pref_punctuation_verbosity,
R.string.pref_punctuation_verbosity_default)));
NumbersAsDigits.setEnabled(
getBooleanPref(
R.string.pref_speak_numbers_as_digits_key,
R.bool.pref_speak_numbers_as_digits_default));
EmojiSpeech.setMode(
this,
SharedPreferencesUtils.getStringPref(
Expand Down
2 changes: 2 additions & 0 deletions talkback/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2173,4 +2173,6 @@
<item type="id" name="voice_profile_menu" />
<string name="pref_restore_focus_on_back_key">pref_restore_focus_on_back</string>
<string name="pref_scroll_to_restore_previous_focus_key">pref_scroll_to_restore_previous_focus</string>
<string name="pref_speak_numbers_as_digits_key" translatable="false">pref_speak_numbers_as_digits</string>
<bool name="pref_speak_numbers_as_digits_default">false</bool>
</resources>
4 changes: 4 additions & 0 deletions talkback/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4509,4 +4509,8 @@ Do not use any markdown in your response."</string>
<string name="title_pref_restore_focus_on_back">Restore focus when going back</string>
<!-- Summary of the phone Advanced Settings switch. Back means returning to the previous app screen or browser page. [CHAR LIMIT=NONE] -->
<string name="summary_pref_restore_focus_on_back">Return to the last focused item when going back to a screen.</string>
<!-- Switch in Verbosity settings. Reads adjacent digits separately instead of as a whole number. [CHAR LIMIT=60] -->
<string name="title_pref_speak_numbers_as_digits">Speak numbers as digits</string>
<!-- Summary of the Verbosity switch. Digits are read separately only for runs of 2 to 32. [CHAR LIMIT=150] -->
<string name="summary_pref_speak_numbers_as_digits">Ask a compatible speech engine to read numbers of 2 to 32 digits individually, including leading zeros.</string>
</resources>
7 changes: 7 additions & 0 deletions talkback/src/main/res/xml/verbosity_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@
android:key="@string/pref_speak_element_ids_key"
android:summary="@string/summary_pref_speak_element_ids"
android:title="@string/title_pref_speak_element_ids" />
<!-- Independent of verbosity presets, like punctuation. -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_speak_numbers_as_digits_default"
android:key="@string/pref_speak_numbers_as_digits_key"
android:title="@string/title_pref_speak_numbers_as_digits"
android:summary="@string/summary_pref_speak_numbers_as_digits" />

<!-- Punctuation - Non-preset detailed settings -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_punctuation_default"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Backtalk contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

package com.google.android.accessibility.talkback.preference.base

import android.content.Context
import android.content.SharedPreferences
import com.google.android.accessibility.talkback.R
import com.google.android.accessibility.utils.SharedPreferencesUtils
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.xmlpull.v1.XmlPullParser

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [26, 34])
class NumbersAsDigitsPreferenceTest {
@Test fun disabledByDefaultAndPersistsChanges() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("digits-test", Context.MODE_PRIVATE)
prefs.edit().clear().commit()
assertFalse(read(prefs, context))
val key = context.getString(R.string.pref_speak_numbers_as_digits_key)
prefs.edit().putBoolean(key, true).commit()
assertTrue(read(prefs, context))
prefs.edit().putBoolean(key, false).commit()
assertFalse(read(prefs, context))
}

@Test fun verbositySwitchUsesPersistentKeyAndDisabledDefault() {
val context = RuntimeEnvironment.getApplication()
context.resources.getXml(R.xml.verbosity_preferences).use { xml ->
var found = 0
while (xml.next() != XmlPullParser.END_DOCUMENT) {
if (xml.eventType != XmlPullParser.START_TAG) continue
val ns = "http://schemas.android.com/apk/res/android"
if (xml.getAttributeResourceValue(ns, "key", 0) != R.string.pref_speak_numbers_as_digits_key) continue
found++
assertEquals(R.bool.pref_speak_numbers_as_digits_default,
xml.getAttributeResourceValue(ns, "defaultValue", 0))
assertTrue(xml.name.endsWith("AccessibilitySuiteSwitchPreference"))
}
assertEquals(1, found)
}
}

private fun read(prefs: SharedPreferences, context: Context) =
SharedPreferencesUtils.getBooleanPref(prefs, context.resources,
R.string.pref_speak_numbers_as_digits_key, R.bool.pref_speak_numbers_as_digits_default)
}
8 changes: 8 additions & 0 deletions talkback/src/wear/res/xml-watch/verbosity_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@
android:title="@string/title_pref_speak_element_ids"
app:splitSummary="true" />

<!-- Independent of verbosity presets, like punctuation. -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_speak_numbers_as_digits_default"
android:key="@string/pref_speak_numbers_as_digits_key"
android:title="@string/title_pref_speak_numbers_as_digits"
android:summary="@string/summary_pref_speak_numbers_as_digits"
app:splitSummary="true" />

<!-- Punctuation - Non-preset detailed settings -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_punctuation_default"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,8 @@ private int speakInChunks(
*/
private int ttsSpeak(
CharSequence text, int queueMode, Bundle bundle, @Nullable String utteranceId) {
// A sliced generated digits span must describe only the submitted slice, not the full run.
text = NumbersAsDigits.forSpeech(text);
@Nullable LowLatencyAudio player = lowLatencyPlayer();
if (player == null || utteranceId == null || TextUtils.isEmpty(text)) {
return tts.speak(text, queueMode, bundle, utteranceId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Backtalk contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

package com.google.android.accessibility.utils.output

import android.text.SpannableString
import android.text.Spanned
import android.text.style.TtsSpan

/** Pronunciation metadata on a speech copy; characters and UTF-16 offsets never change. */
object NumbersAsDigits {
private const val MAX_DIGITS = 32
@Volatile private var enabled = false

@JvmStatic
fun setEnabled(value: Boolean) {
enabled = value
}

/** Decide eligibility before formatting or chunking can split an over-limit run. */
@JvmStatic
fun apply(source: CharSequence?): CharSequence? {
if (!enabled || source == null) return source
var speech: SpannableString? = null
var start = 0
while (start < source.length) {
val point = Character.codePointAt(source, start)
if (!Character.isDigit(point)) {
start += Character.charCount(point)
continue
}
var end = start
var count = 0
while (end < source.length && Character.isDigit(Character.codePointAt(source, end))) {
end += Character.charCount(Character.codePointAt(source, end))
count++
}
// Skip the entire run, never a 32-digit prefix of a longer number.
if (count in 2..MAX_DIGITS && !hasInstruction(source, start, end)) {
val copy = speech ?: SpannableString(source).also { speech = it }
copy.setSpan(
DigitsSpan(source.subSequence(start, end).toString()),
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
start = end
}
return speech ?: source
}

/** Clip only our generated arguments when the existing pipeline slices prepared text. */
@JvmStatic
fun forSpeech(source: CharSequence?): CharSequence? {
if (source !is Spanned) return source
var speech: SpannableString? = null
for (span in source.getSpans(0, source.length, DigitsSpan::class.java)) {
val start = source.getSpanStart(span)
val end = source.getSpanEnd(span)
val digits = source.subSequence(start, end).toString()
if (digits == span.args.getString(TtsSpan.ARG_DIGITS)) continue
val copy = speech ?: SpannableString(source).also { speech = it }
copy.removeSpan(span)
// Formatting must not turn a numeric instruction into an unrelated replacement.
if (digits.isNotEmpty() && digits.codePoints().allMatch { Character.isDigit(it) } &&
digits.codePointCount(0, digits.length) <= MAX_DIGITS) {
copy.setSpan(DigitsSpan(digits), start, end, source.getSpanFlags(span))
}
}
return speech ?: source
}

private fun hasInstruction(source: CharSequence, start: Int, end: Int): Boolean =
source is Spanned && source.getSpans(start, end, TtsSpan::class.java).any {
source.getSpanStart(it) < end && source.getSpanEnd(it) > start &&
source.getSpanStart(it) < source.getSpanEnd(it)
}

// A standard TYPE_DIGITS span on the wire. The subtype lets us adjust our own sliced
// instructions without changing explicit application-provided TtsSpan arguments.
private class DigitsSpan(digits: String) :
TtsSpan(TtsSpan.TYPE_DIGITS, TtsSpan.DigitsBuilder().setDigits(digits).build().args)
}
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,7 @@ public void speak(
text = replaceSpanByContentDescription(text);
text = replaceBrailleSymbolByDescription(mContext, text);
text = EmojiSpeech.process(mContext, text);
text = NumbersAsDigits.apply(text);
final FeedbackItem pendingItem =
FeedbackProcessingUtils.generateFeedbackItemFromInput(
mContext,
Expand Down
Loading
Loading