chromium/chrome/browser/resources/side_panel/read_anything/voice_language_util.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

export type VoicePackStatus = VoicePackServerResponseSuccess|
    VoicePackServerResponseError|VoicePackServerResponseParsingError;

const STATUS_SUCCESS = 'Successful response';
const STATUS_FAILURE = 'Unsuccessful response';
const PARSING_ERROR = 'Cannot parse LanguagePackManager response';

// Representation of server-side LanguagePackManager state
interface VoicePackServerResponseSuccess {
  id: 'Successful response';
  code: VoicePackServerStatusSuccessCode;
}
interface VoicePackServerResponseError {
  id: 'Unsuccessful response';
  code: VoicePackServerStatusErrorCode;
}

interface VoicePackServerResponseParsingError {
  id: 'Cannot parse LanguagePackManager response';
  code: 'ParseError';
}

export function isVoicePackStatusSuccess(status?: VoicePackStatus):
    status is VoicePackServerResponseSuccess {
  if (status === undefined) {
    return false;
  }
  return status.id === STATUS_SUCCESS;
}

export function isVoicePackStatusError(status?: VoicePackStatus):
    status is VoicePackServerResponseError {
  if (status === undefined) {
    return false;
  }

  return status.id === STATUS_FAILURE;
}

// Representation of server-side LanguagePackManager state. Roughly corresponds
// to InstallationState in read_anything.mojom
export enum VoicePackServerStatusSuccessCode {
  NOT_INSTALLED,  // Available to be downloaded but not installed
  INSTALLING,     // Currently installing
  INSTALLED,      // Is downloaded onto the device
}

// Representation of server-side LanguagePackManager state. Roughly corresponds
// to ErrorCode in read_anything.mojom. We treat many of these errors in the
// same way, but these are the states that the server sends us.
export enum VoicePackServerStatusErrorCode {
  OTHER,                 // A catch all error
  WRONG_ID,              // If no language pack for this language
  NEED_REBOOT,           // Error installing and a reboot should help
  ALLOCATION,            // Error due to not enough memory
  UNSUPPORTED_PLATFORM,  // Donloads not supported on this platform
}

// Our client-side representation tracking voice-pack states.
export enum VoiceClientSideStatusCode {
  NOT_INSTALLED,         // Available to be downloaded but not installed
  SENT_INSTALL_REQUEST,  // We sent an install request
  SENT_INSTALL_REQUEST_ERROR_RETRY,  // We sent an install request retrying a
                                     // previously failed download
  INSTALLED_AND_UNAVAILABLE,  // The server says voice is on disk, but it's not
                              // available to the local speechSynthesis API yet
  AVAILABLE,  // The voice is installed and available to be used by the local
              // speechSynthesis API
  ERROR_INSTALLING,          // Couldn't install
  INSTALL_ERROR_ALLOCATION,  // Couldn't install due to not enough memory
}

// These strings are not localized and will be in English, even for non-English
// Natural and eSpeak voices.
const NATURAL_STRING_IDENTIFIER = '(Natural)';
const ESPEAK_STRING_IDENTIFIER = 'eSpeak';

// Helper for filtering the voice list broken into a separate method
// that doesn't modify instance data to simplify testing.
export function getFilteredVoiceList(possibleVoices: SpeechSynthesisVoice[]):
    SpeechSynthesisVoice[] {
  let availableVoices = possibleVoices;
  if (availableVoices.some(({localService}) => localService)) {
    availableVoices = availableVoices.filter(({localService}) => localService);
  }
  // Filter out Android voices on ChromeOS. Android Speech Recognition
  // voices are technically network voices, but for some reason, some
  // voices are marked as localService voices, so filtering localService
  // doesn't filter them out. Since they can cause unexpected behavior
  // in Read Aloud, go ahead and filter them out. To avoid causing any
  // unexpected behavior outside of ChromeOS, just filter them on ChromeOS.
  if (chrome.readingMode.isChromeOsAsh) {
    availableVoices = availableVoices.filter(
        ({name}) => !name.toLowerCase().includes('android'));
  }
  // Filter out espeak voices if there exists a Google voice in the same
  // locale.
  if (chrome.readingMode.isChromeOsAsh) {
    availableVoices = availableVoices.filter(
        voice => !isEspeak(voice) ||
            convertLangOrLocaleToExactVoicePackLocale(voice.lang) ===
                undefined);
  }

  return availableVoices;
}

export function isNatural(voice: SpeechSynthesisVoice) {
  return voice.name.includes(NATURAL_STRING_IDENTIFIER);
}

export function isEspeak(voice: SpeechSynthesisVoice|undefined) {
  return voice && voice.name.includes(ESPEAK_STRING_IDENTIFIER);
}

export function getNaturalVoiceOrDefault(voices: SpeechSynthesisVoice[]):
    SpeechSynthesisVoice {
  const naturalVoice = voices.find(v => isNatural(v));
  if (naturalVoice) {
    return naturalVoice;
  }

  const defaultVoice =
      voices.find(({default: isDefaultVoice}) => isDefaultVoice);
  return defaultVoice ? defaultVoice : voices[0];
}

export function createInitialListOfEnabledLanguages(
    browserOrPageBaseLang: string, storedLanguagesPref: string[],
    availableLangs: string[], langOfDefaultVoice: string|undefined): string[] {
  const initialAvailableLanguages: Set<string> = new Set();

  // Add stored prefs to initial list of enabled languages
  for (const lang of storedLanguagesPref) {
    // Find the version of the lang/locale that maps to a language
    const matchingLang =
        convertLangToAnAvailableLangIfPresent(lang, availableLangs);
    if (matchingLang) {
      initialAvailableLanguages.add(matchingLang);
    }
  }

  // Add browserOrPageBaseLang to initial list of enabled languages
  // If there's no locale/base-lang already matching in
  // initialAvailableLanguages, then add one
  const browserPageLangAlreadyPresent = [...initialAvailableLanguages].some(
      lang => lang.startsWith(browserOrPageBaseLang));
  if (!browserPageLangAlreadyPresent) {
    const matchingLangOfBrowserLang = convertLangToAnAvailableLangIfPresent(
        browserOrPageBaseLang, availableLangs);
    if (matchingLangOfBrowserLang) {
      initialAvailableLanguages.add(matchingLangOfBrowserLang);
    }
  }

  // If initialAvailableLanguages is still empty, add the default voice
  // language
  if (initialAvailableLanguages.size === 0) {
    if (langOfDefaultVoice) {
      initialAvailableLanguages.add(langOfDefaultVoice);
    }
  }

  return [...initialAvailableLanguages];
}

export function convertLangToAnAvailableLangIfPresent(
    langOrLocale: string, availableLangs: string[],
    allowCurrentLanguageIfExists: boolean = true): string|undefined {
  // Convert everything to lower case
  langOrLocale = langOrLocale.toLowerCase();
  availableLangs = availableLangs.map(lang => lang.toLowerCase());

  if (allowCurrentLanguageIfExists && availableLangs.includes(langOrLocale)) {
    return langOrLocale;
  }

  const baseLang = extractBaseLang(langOrLocale);
  if (allowCurrentLanguageIfExists && availableLangs.includes(baseLang)) {
    return baseLang;
  }

  // See if there are any matching available locales we can default to
  const matchingLocales: string[] = availableLangs.filter(
      availableLang => extractBaseLang(availableLang) === baseLang);
  if (matchingLocales && matchingLocales[0]) {
    return matchingLocales[0];
  }

  // If all else fails, try the browser language.
  const defaultLanguage =
      chrome.readingMode.defaultLanguageForSpeech.toLowerCase();
  if (availableLangs.includes(defaultLanguage)) {
    return defaultLanguage;
  }

  // Try the browser language converted to a locale.
  const convertedDefaultLanguage =
      convertUnsupportedBaseLangToSupportedLocale(defaultLanguage);
  if (convertedDefaultLanguage &&
      availableLangs.includes(convertedDefaultLanguage)) {
    return convertedDefaultLanguage;
  }

  return undefined;
}

// The following possible values of "status" is a union of enum values of
// enum InstallationState and enum ErrorCode in read_anything.mojom
export function mojoVoicePackStatusToVoicePackStatusEnum(
    mojoPackStatus: string): VoicePackStatus {
  if (mojoPackStatus === 'kNotInstalled') {
    return {
      id: STATUS_SUCCESS,
      code: VoicePackServerStatusSuccessCode.NOT_INSTALLED,
    };
  } else if (mojoPackStatus === 'kInstalling') {
    return {
      id: STATUS_SUCCESS,
      code: VoicePackServerStatusSuccessCode.INSTALLING,
    };
  } else if (mojoPackStatus === 'kInstalled') {
    return {
      id: STATUS_SUCCESS,
      code: VoicePackServerStatusSuccessCode.INSTALLED,
    };
  } else if (mojoPackStatus === 'kOther' || mojoPackStatus === 'kUnknown') {
    return {id: STATUS_FAILURE, code: VoicePackServerStatusErrorCode.OTHER};
  } else if (mojoPackStatus === 'kWrongId') {
    return {id: STATUS_FAILURE, code: VoicePackServerStatusErrorCode.WRONG_ID};
  } else if (mojoPackStatus === 'kNeedReboot') {
    return {
      id: STATUS_FAILURE,
      code: VoicePackServerStatusErrorCode.NEED_REBOOT,
    };
  } else if (mojoPackStatus === 'kAllocation') {
    return {
      id: STATUS_FAILURE,
      code: VoicePackServerStatusErrorCode.ALLOCATION,
    };
  } else if (mojoPackStatus === 'kUnsupportedPlatform') {
    return {
      id: STATUS_FAILURE,
      code: VoicePackServerStatusErrorCode.UNSUPPORTED_PLATFORM,
    };
  } else {
    return {id: PARSING_ERROR, code: 'ParseError'};
  }
}

// TODO: b/40927698: Make this private and use getVoicePackConvertedLangIfExists
// instead.
// The ChromeOS VoicePackManager labels some voices by locale, and some by
// base-language. The request for each needs to be exact, so this function
// converts a locale or language into the code the VoicePackManager expects.
// This is based on the VoicePackManager code here:
// https://source.chromium.org/chromium/chromium/src/+/main:chromeos/ash/components/language_packs/language_pack_manager.cc;l=346;drc=31e516b25930112df83bf09d3d2a868200ecbc6d
export function convertLangOrLocaleForVoicePackManager(
    langOrLocale: string, enabledLangs?: string[],
    availableLangs?: string[]): string|undefined {
  langOrLocale = langOrLocale.toLowerCase();
  if (PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES.has(langOrLocale)) {
    return langOrLocale;
  }

  if (!isBaseLang(langOrLocale)) {
    const baseLang = langOrLocale.substring(0, langOrLocale.indexOf('-'));
    if (PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES.has(baseLang)) {
      return baseLang;
    }
    const locale = convertUnsupportedBaseLangToSupportedLocale(
        baseLang, enabledLangs, availableLangs);
    if (locale) {
      return locale;
    }
  }

  const locale = convertUnsupportedBaseLangToSupportedLocale(
      langOrLocale, enabledLangs, availableLangs);
  if (locale) {
    return locale;
  }

  return undefined;
}

export function convertLangOrLocaleToExactVoicePackLocale(langOrLocale: string):
    string|undefined {
  const possibleConvertedLang =
      convertLangOrLocaleForVoicePackManager(langOrLocale);
  if (!possibleConvertedLang) {
    return possibleConvertedLang;
  }

  return [...AVAILABLE_GOOGLE_TTS_LOCALES].find(
      locale => locale.startsWith(possibleConvertedLang.toLowerCase()));
}

export function isWaitingForInstallLocally(status: VoiceClientSideStatusCode|
                                           undefined) {
  return status === VoiceClientSideStatusCode.SENT_INSTALL_REQUEST ||
      status === VoiceClientSideStatusCode.SENT_INSTALL_REQUEST_ERROR_RETRY;
}

function convertUnsupportedBaseLangToSupportedLocale(
    baseLang: string, enabledLangs?: string[],
    availableLangs?: string[]): string|undefined {
  // Check if it's a base lang that supports a locale. These are the only
  // languages that have locales in the Pack Manager per the code link above.
  if (!['en', 'es', 'pt'].includes(baseLang)) {
    return undefined;
  }

  // If enabledLangs is not null, then choose an enabled locale for this given
  // language so we don't unnecessarily enable other locales when one is already
  // enabled.
  if (enabledLangs) {
    const enabledLocalesForLang =
        enabledLangs.filter(lang => lang.startsWith(baseLang));
    if (enabledLocalesForLang.length > 0) {
      // TODO(crbug.com/335691447): If there is more than one enabled locale for
      // this lang, choose one based on browser prefs. For now, just default to
      // the first enabled locale.
      return enabledLocalesForLang[0];
    }
  }

  // If availableLangs is not null, then choose an available locale for this
  // given language so we don't unnecessarily download other locales when one is
  // already downloaded.
  if (availableLangs) {
    const availableLocalesForLang =
        availableLangs.filter(lang => lang.startsWith(baseLang));
    if (availableLocalesForLang.length > 0) {
      // TODO(crbug.com/335691447): If there is more than one available locale
      // for this lang, choose one based on browser prefs. For now, just default
      // to the first available locale.
      return availableLocalesForLang[0];
    }
  }

  // TODO(crbug.com/335691447): Convert from base-lang to locale based on
  // browser prefs.

  // Otherwise, just default to arbitrary locales.
  if (baseLang === 'en') {
    return 'en-us';
  } else if (baseLang === 'es') {
    return 'es-es';
  } else {
    return 'pt-br';
  }
}

// Returns true if input is base lang, and false if it's a locale
function isBaseLang(langOrLocale: string): boolean {
  return !langOrLocale.includes('-');
}

function extractBaseLang(langOrLocale: string): string {
  if (isBaseLang(langOrLocale)) {
    return langOrLocale;
  }
  return langOrLocale.substring(0, langOrLocale.indexOf('-'));
}

export function doesLanguageHaveNaturalVoices(language: string): boolean {
  const voicePackLanguage = getVoicePackConvertedLangIfExists(language);

  return NATURAL_VOICES_SUPPORTED_LANGS_AND_LOCALES.has(voicePackLanguage);
}

export function getVoicePackConvertedLangIfExists(lang: string): string {
  const voicePackLanguage = convertLangOrLocaleForVoicePackManager(lang);

  // If the voice pack language wasn't converted, use the original string.
  // This will enable us to set install statuses on invalid languages and
  // locales.
  if (!voicePackLanguage) {
    return lang;
  }

  return voicePackLanguage;
}

// These are from the Pack Manager. Values should be kept in sync with the code
// link above.
export const PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES = new Set([
  'bn',    'cs', 'da',  'de', 'el', 'en-au', 'en-gb', 'en-us', 'es-es',
  'es-us', 'fi', 'fil', 'fr', 'hi', 'hu',    'id',    'it',    'ja',
  'km',    'ko', 'nb',  'ne', 'nl', 'pl',    'pt-br', 'pt-pt', 'si',
  'sk',    'sv', 'th',  'tr', 'uk', 'vi',    'yue',
]);

// If there is a natural voice available for this language, based on
// voices_list.csv. If there is a voice in
// PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES but not in this list, it means
// we still need to call to the pack manager to install the voice pack but
// there are no natural voices associate with the language.
// Currently, 'yue' and 'km' are the only two pack supported languages not
// included in this list.
const NATURAL_VOICES_SUPPORTED_LANGS_AND_LOCALES = new Set([
  'bn',    'cs',    'da', 'de',  'el', 'en-au', 'en-gb', 'en-us',
  'es-es', 'es-us', 'fi', 'fil', 'fr', 'hi',    'hu',    'id',
  'it',    'ja',    'ko', 'nb',  'ne', 'nl',    'pl',    'pt-br',
  'pt-pt', 'si',    'sk', 'sv',  'th', 'tr',    'uk',    'vi',
]);

// These are the locales based on PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES, but
// for the actual Google TTS locales that can be installed on ChromeOS. While
// we can use the languages in PACK_MANAGER_SUPPORTED_LANGS_AND_LOCALES to
// download a voice pack, the voice pack language code will be returned in
// the locale format, as in AVAILABLE_GOOGLE_TTS_LOCALES, which means the
// previously toggled language item won't match the language item associated
// with the downloaded pack.
export const AVAILABLE_GOOGLE_TTS_LOCALES = new Set([
  'bn-bd', 'cs-cz', 'da-dk', 'de-de', 'el-gr',  'en-au',  'en-gb',
  'en-us', 'es-es', 'es-us', 'fi-fi', 'fil-ph', 'fr-fr',  'hi-in',
  'hu-hu', 'id-id', 'it-it', 'ja-jp', 'km-kh',  'ko-kr',  'nb-no',
  'ne-np', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt',  'si-lk',  'sk-sk',
  'sv-se', 'th-th', 'tr-tr', 'uk-ua', 'vi-vn',  'yue-hk',
]);

export function areVoicesEqual(
    voice1: SpeechSynthesisVoice|undefined,
    voice2: SpeechSynthesisVoice|undefined): boolean {
  if (!voice1 || !voice2) {
    return false;
  }
  return voice1.default === voice2.default && voice1.lang === voice2.lang &&
      voice1.localService === voice2.localService &&
      voice1.name === voice2.name && voice1.voiceURI === voice2.voiceURI;
}