// 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;
}
}