chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/abstract_tts.ts

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

import {Msgs} from '../common/msgs.js';
import {SettingsManager} from '../common/settings_manager.js';
import * as ttsTypes from '../common/tts_types.js';

import {TtsCapturingEventListener, TtsInterface} from './tts_interface.js';

interface PropertyValues {
  pitch: number;
  rate: number;
  volume: number;

  [key: string]: number | undefined;
}

interface Properties {
  [key: string]: number | undefined;
}

/**
 * Base class for Text-to-Speech engines that actually transform
 * text to speech (as opposed to logging or other behaviors).
 */
export class AbstractTts implements TtsInterface {
  /**
   * Default value for TTS properties.
   * Note that these as well as the subsequent properties might be different
   * on different host platforms (like Chrome, Android, etc.).
   */
  protected propertyDefault: PropertyValues;
  /** Min value for TTS properties. */
  protected propertyMin: PropertyValues;
  /** Max value for TTS properties. */
  protected propertyMax: PropertyValues;
  /** Step value for TTS properties. */
  protected propertyStep: PropertyValues;
  /** Default TTS properties for this TTS engine. */
  protected ttsProperties: Properties = {};

  /** Substitution dictionary regexp. */
  private static substitutionDictionaryRegexp_: RegExp;
  /** Repetition filter regexp. */
  private static repetitionRegexp_: RegExp =
      /([-\/\\|!@#$%^&*\(\)=_+\[\]\{\}.?;'":<>\u2022\u25e6\u25a0])\1{2,}/g;
  /** Regexp filter for negative dollar and pound amounts. */
  private static negativeCurrencyAmountRegexp_: RegExp =
      /-[£\$](\d{1,3})(\d+|(,\d{3})*)(\.\d{1,})?/g;

  constructor() {
    const pitchDefault = 1;
    const pitchMin = 0.2;
    const pitchMax = 2.0;
    const pitchStep = 0.1;

    const rateDefault = 1;
    const rateMin = 0.2;
    const rateMax = 5.0;
    const rateStep = 0.1;

    const volumeDefault = 1;
    const volumeMin = 0.2;
    const volumeMax = 1.0;
    const volumeStep = 0.1;

    this.propertyDefault = {
      pitch: pitchDefault,
      rate: rateDefault,
      volume: volumeDefault,
    };

    this.propertyMin = {
      pitch: pitchMin,
      rate: rateMin,
      volume: volumeMin,
    };

    this.propertyMax = {
      pitch: pitchMax,
      rate: rateMax,
      volume: volumeMax,
    };

    this.propertyStep = {rate: rateStep, pitch: pitchStep, volume: volumeStep};

    if (AbstractTts.substitutionDictionaryRegexp_ === undefined) {
      // Create an expression that matches all words in the substitution
      // dictionary.
      const symbols: string[] = [];
      for (const symbol in ttsTypes.SubstitutionDictionary) {
        symbols.push(symbol);
      }
      const expr = '(' + symbols.join('|') + ')';
      AbstractTts.substitutionDictionaryRegexp_ = new RegExp(expr, 'ig');
    }
  }

  /** TtsInterface implementation. */
  speak(
      _textString: string, _queueMode: ttsTypes.QueueMode,
      _properties?: ttsTypes.TtsSpeechProperties): AbstractTts {
    return this;
  }

  /** TtsInterface implementation. */
  isSpeaking(): boolean {
    return false;
  }

  /** TtsInterface implementation. */
  stop(): void {}

  /** TtsInterface implementation. */
  addCapturingEventListener(_listener: TtsCapturingEventListener): void {}

  /** TtsInterface implementation. */
  removeCapturingEventListener(_listener: TtsCapturingEventListener): void {}

  /** TtsInterface implementation. */
  increaseOrDecreaseProperty(propertyName: string, increase: boolean): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const step = this.propertyStep[propertyName]!;
    let current = this.ttsProperties[propertyName]!;
    current = increase ? current + step : current - step;
    this.setProperty(propertyName, current);
  }

  /** TtsInterface implementation. */
  setProperty(propertyName: string, value: number): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const min = this.propertyMin[propertyName]!;
    const max = this.propertyMax[propertyName]!;
    this.ttsProperties[propertyName] = Math.max(Math.min(value, max), min);
  }

  /**
   * Converts an engine property value to a percentage from 0.00 to 1.00.
   * @param property The property to convert.
   * @return The percentage of the property.
   */
  propertyToPercentage(property: string): number|null {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    return (this.ttsProperties[property]! - this.propertyMin[property]!) /
        Math.abs(this.propertyMax[property]! - this.propertyMin[property]!);
  }

  /**
   * Merges the given properties with the default ones. Always returns a
   * new object, so that you can safely modify the result of mergeProperties
   * without worrying that you're modifying an object used elsewhere.
   * @param properties The properties to merge with the current ones.
   * @return The merged properties.
   */
  protected mergeProperties(properties: Properties): Properties {
    const mergedProperties: Properties = {};
    let p;
    if (this.ttsProperties) {
      for (p in this.ttsProperties) {
        mergedProperties[p] = this.ttsProperties[p];
      }
    }
    if (properties) {
      const tts = ttsTypes.TtsSettings;
      if (typeof (properties[tts.VOLUME]) === 'number') {
        mergedProperties[tts.VOLUME] = properties[tts.VOLUME];
      }
      if (typeof (properties[tts.PITCH]) === 'number') {
        mergedProperties[tts.PITCH] = properties[tts.PITCH];
      }
      if (typeof (properties[tts.RATE]) === 'number') {
        mergedProperties[tts.RATE] = properties[tts.RATE];
      }
      if (typeof (properties[tts.LANG]) === 'string') {
        mergedProperties[tts.LANG] = properties[tts.LANG];
      }

      const context = this;
      const mergeRelativeProperty = function(abs: string, rel: string): void {
        if (typeof (properties[rel]) === 'number' &&
            typeof (mergedProperties[abs]) === 'number') {
          mergedProperties[abs] += properties[rel];
          // TODO(b/314203187): Not null asserted, check that this is correct.
          const min = context.propertyMin[abs]!;
          const max = context.propertyMax[abs]!;
          if (mergedProperties[abs] > max) {
            mergedProperties[abs] = max;
          } else if (mergedProperties[abs] < min) {
            mergedProperties[abs] = min;
          }
        }
      };

      mergeRelativeProperty(tts.VOLUME, tts.RELATIVE_VOLUME);
      mergeRelativeProperty(tts.PITCH, tts.RELATIVE_PITCH);
      mergeRelativeProperty(tts.RATE, tts.RELATIVE_RATE);
    }

    for (p in properties) {
      if (!mergedProperties.hasOwnProperty(p)) {
        mergedProperties[p] = properties[p];
      }
    }

    return mergedProperties;
  }

  /**
   * Method to preprocess text to be spoken properly by a speech
   * engine.
   *
   * 1. Replace any single character with a description of that character.
   *
   * 2. Convert all-caps words to lowercase if they don't look like an
   *    acronym / abbreviation.
   *
   * @param text A text string to be spoken.
   * @param properties Out parameter populated with how to speak the string.
   * @return The text formatted in a way that will sound better by most speech
   *     engines.
   */
  protected preprocess(text: string, properties: Properties = {}): string {
    if (text.length === 1 && text.toLowerCase() !== text) {
      // Describe capital letters according to user's setting.
      if (SettingsManager.getString('capitalStrategy') === 'increasePitch') {
        // Closure doesn't allow the use of for..in or [] with structs, so
        // convert to a pure JSON object.
        const CAPITAL = ttsTypes.Personality.CAPITAL.toJSON() as PropertyValues;
        for (const prop in CAPITAL) {
          if (properties[prop] === undefined) {
            properties[prop] = CAPITAL[prop];
          }
        }
      } else if (
          SettingsManager.getString('capitalStrategy') === 'announceCapitals') {
        text = Msgs.getMsg('announce_capital_letter', [text]);
      }
    }

    if (!SettingsManager.getBoolean('usePitchChanges')) {
      delete properties['relativePitch'];
    }

    // Since dollar and sterling pound signs will be replaced with text, move
    // them to after the number if they stay between a negative sign and a
    // number.
    text = text.replace(AbstractTts.negativeCurrencyAmountRegexp_, match => {
      const minus = match[0];
      const number = match.substring(2);
      const currency = match[1];

      return minus + number + currency;
    });

    // Substitute all symbols in the substitution dictionary. This is pretty
    // efficient because we use a single regexp that matches all symbols
    // simultaneously.
    text = text.replace(
        AbstractTts.substitutionDictionaryRegexp_, function(symbol) {
          return ' ' + ttsTypes.SubstitutionDictionary[symbol] + ' ';
        });

    // Handle single characters that we want to make sure we pronounce.
    if (text.length === 1) {
      return ttsTypes.CharacterDictionary[text] ?
          Msgs.getMsgWithCount(ttsTypes.CharacterDictionary[text], 1) :
          text.toUpperCase();
    }

    // Expand all repeated characters.
    text = text.replace(
        AbstractTts.repetitionRegexp_, AbstractTts.repetitionReplace_);

    return text;
  }

  /**
   * Constructs a description of a repeated character. Use as a param to
   * string.replace.
   * @param match The matching string.
   * @return The description.
   */
  private static repetitionReplace_(match: string): string {
    const count = match.length;
    return ' ' +
        Msgs.getMsgWithCount(ttsTypes.CharacterDictionary[match[0]], count) +
        ' ';
  }

  /** TtsInterface implementation. */
  getDefaultProperty(property: string): number {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    return this.propertyDefault[property]!;
  }

  /** TtsInterface implementation. */
  toggleSpeechOnOrOff(): boolean {
    return true;
  }
}