/**
* Copyright 2000-2006 DFKI GmbH.
* All Rights Reserved. Use is subject to license terms.
*
* This file is part of MARY TTS.
*
* MARY TTS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package marytts.modules.phonemiser;
/**
* @author ingmar
*/
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import marytts.exceptions.MaryConfigurationException;
import marytts.util.MaryUtils;
import marytts.util.dom.DomUtils;
import org.apache.commons.lang.StringUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.traversal.NodeIterator;
public class AllophoneSet {
private static Map<String, AllophoneSet> allophoneSets = new HashMap<String, AllophoneSet>();
/**
* Return the allophone set specified by the given filename. It will only be loaded if it was not loaded before.
*
* @param filename
* filename
* @return the allophone set, if one can be created. This method will never return null.
* @throws MaryConfigurationException
* if no allophone set can be loaded from the given file.
*/
public static AllophoneSet getAllophoneSet(String filename) throws MaryConfigurationException {
InputStream fis = null;
try {
fis = new FileInputStream(filename);
} catch (IOException e) {
throw new MaryConfigurationException("Problem reading allophone file " + filename, e);
}
assert fis != null;
return getAllophoneSet(fis, filename);
}
/**
* Determine whether the registry of previously loaded allophone sets already contains an allophone set with the given
* identifier. If this returns true, then a call to {@link #getAllophoneSetById(String)} with the same identifier will return
* a non-null Allophone set.
*
* @param identifier
* the identifier of the allophone set to test.
* @return true if the registry already contains the given allophone set, false otherwise.
*/
public static boolean hasAllophoneSet(String identifier) {
return allophoneSets.containsKey(identifier);
}
/**
* Get a previously loaded allophone set by its identifier. The method will make no attempt to load the allophone set if it is
* not yet available.
*
* @param identifier
* the identifier of the allophone set
* @return the allophone set if available, null otherwise.
*/
public static AllophoneSet getAllophoneSetById(String identifier) {
return allophoneSets.get(identifier);
}
/**
* Return the allophone set that can be read from the given input stream, identified by the given identifier. It will only be
* loaded if it was not loaded before.
*
* @param inStream
* an open stream from which the allophone set can be loaded. it will be closed when this method returns.
* @param identifier
* a unique identifier for this allophone set.
* @return the allophone set, if one can be created. This method will never return null.
* @throws MaryConfigurationException
* if no allophone set can be loaded from the given file.
*/
public static AllophoneSet getAllophoneSet(InputStream inStream, String identifier) throws MaryConfigurationException {
AllophoneSet as = allophoneSets.get(identifier);
if (as == null) {
// Need to load it:
try {
as = new AllophoneSet(inStream);
} catch (MaryConfigurationException e) {
throw new MaryConfigurationException("Problem loading allophone set from " + identifier, e);
}
allophoneSets.put(identifier, as);
} else {
try {
inStream.close();
} catch (IOException e) {
// ignore
}
}
assert as != null;
return as;
}
// //////////////////////////////////////////////////////////////////
private String name; // the name of the allophone set
private Locale locale; // the locale of the allophone set, e.g. US English
// The map of segment objects, indexed by their phonetic symbol:
private Map<String, Allophone> allophones = null;
// Map feature names to the list of possible values in this AllophoneSet
private Map<String, String[]> featureValueMap = null;
private Allophone silence = null;
private String ignore_chars = null;
// The number of characters in the longest Allophone symbol
private int maxAllophoneSymbolLength = 1;
private AllophoneSet(InputStream inputStream) throws MaryConfigurationException {
allophones = new TreeMap<String, Allophone>();
// parse the xml file:
Document document;
try {
document = DomUtils.parseDocument(inputStream);
} catch (Exception e) {
throw new MaryConfigurationException("Cannot parse allophone file", e);
} finally {
try {
inputStream.close();
} catch (IOException ioe) {
// ignore
}
}
Element root = document.getDocumentElement();
name = root.getAttribute("name");
String xmlLang = root.getAttribute("xml:lang");
locale = MaryUtils.string2locale(xmlLang);
String[] featureNames = root.getAttribute("features").split(" ");
if (root.hasAttribute("ignore_chars")) {
ignore_chars = root.getAttribute("ignore_chars");
}
NodeIterator ni = DomUtils.createNodeIterator(document, root, "vowel", "consonant", "silence", "tone");
Element a;
while ((a = (Element) ni.nextNode()) != null) {
Allophone ap = new Allophone(a, featureNames);
if (allophones.containsKey(ap.name()))
throw new MaryConfigurationException("File contains duplicate definition of allophone '" + ap.name() + "'!");
allophones.put(ap.name(), ap);
if (ap.isPause()) {
if (silence != null)
throw new MaryConfigurationException("File contains more than one silence symbol: '" + silence.name()
+ "' and '" + ap.name() + "'!");
silence = ap;
}
int len = ap.name().length();
if (len > maxAllophoneSymbolLength) {
maxAllophoneSymbolLength = len;
}
}
if (silence == null)
throw new MaryConfigurationException("File does not contain a silence symbol");
// Fill the list of possible values for all features
// such that "0" comes first and all other values are sorted alphabetically
featureValueMap = new TreeMap<String, String[]>();
for (String feature : featureNames) {
Set<String> featureValueSet = new TreeSet<String>();
for (Allophone ap : allophones.values()) {
featureValueSet.add(ap.getFeature(feature));
}
if (featureValueSet.contains("0"))
featureValueSet.remove("0");
String[] featureValues = new String[featureValueSet.size() + 1];
featureValues[0] = "0";
int i = 1;
for (String f : featureValueSet) {
featureValues[i++] = f;
}
featureValueMap.put(feature, featureValues);
}
// Special "vc" feature:
featureValueMap.put("vc", new String[] { "0", "+", "-" });
}
public Locale getLocale() {
return locale;
}
/**
* Get the Allophone with the given name
*
* @param ph
* name of Allophone to get
* @return the Allophone
* @throws IllegalArgumentException
* if the Allophone is not found in the AllophoneSet
*/
public Allophone getAllophone(String ph) {
Allophone allophone = allophones.get(ph);
if (allophone == null) {
throw new IllegalArgumentException(String.format(
"Allophone `%s' could not be found in AllophoneSet `%s' (Locale: %s)", ph, name, locale));
}
return allophone;
}
/**
* Obtain the silence allophone in this AllophoneSet
*
* @return silence
*/
public Allophone getSilence() {
return silence;
}
/**
* Obtain the ignore chars in this AllophoneSet Default: "',-"
*
* @return ignore_chars
*/
public String getIgnoreChars() {
if (ignore_chars == null) {
return "',-";
} else {
return ignore_chars;
}
}
/**
* For the Allophone with name ph, return the value of the named feature.
*
* @param ph
* ph
* @param featureName
* feature name
* @return the allophone feature, or null if either the allophone or the feature does not exist.
*/
public String getPhoneFeature(String ph, String featureName) {
if (ph == null)
return null;
Allophone a = allophones.get(ph);
if (a == null)
return null;
return a.getFeature(featureName);
}
/**
* Get the list of available phone features for this allophone set.
*
* @return Collections.unmodifiableSet(featureValueMap.keySet())
*/
public Set<String> getPhoneFeatures() {
return Collections.unmodifiableSet(featureValueMap.keySet());
}
/**
* For the given feature name, get the list of all possible values that the feature can take in this allophone set.
*
* @param featureName
* featureName
* @throws IllegalArgumentException
* if featureName is not a known feature name.
* @return the list of values, "0" first.
*/
public String[] getPossibleFeatureValues(String featureName) {
String[] vals = featureValueMap.get(featureName);
if (vals == null)
throw new IllegalArgumentException("No such feature: " + featureName);
return vals;
}
/**
* This returns the names of all allophones contained in this AllophoneSet, as a Set of Strings
*
* @return allophoneKeySet
*/
public Set<String> getAllophoneNames() {
Iterator<String> it = allophones.keySet().iterator();
Set<String> allophoneKeySet = new TreeSet<String>();
while (it.hasNext()) {
String keyString = it.next();
if (!allophones.get(keyString).isTone()) {
allophoneKeySet.add(keyString);
}
}
return allophoneKeySet;
}
/**
* Split a phonetic string into allophone symbols. Symbols representing primary and secondary stress, syllable boundaries, and
* spaces, will be silently skipped.
*
* @param allophoneString
* the phonetic string to split
* @return an array of Allophone objects corresponding to the string given as input
* @throws IllegalArgumentException
* if the allophoneString contains unknown symbols.
*/
public Allophone[] splitIntoAllophones(String allophoneString) {
List<String> phones = splitIntoAllophoneList(allophoneString, false);
Allophone[] allos = new Allophone[phones.size()];
for (int i = 0; i < phones.size(); i++) {
try {
allos[i] = getAllophone(phones.get(i));
} catch (IllegalArgumentException e) {
throw e;
}
}
return allos;
}
/**
* Split allophone string into a list of allophone symbols. Include stress markers (',) and syllable boundaries (-), skip
* space characters.
*
* @param allophoneString
* allophoneString
* @throws IllegalArgumentException
* if the string contains illegal symbols.
* @return a String containing allophones and stress markers / syllable boundaries, separated with spaces
*/
public String splitAllophoneString(String allophoneString) {
List<String> phones = splitIntoAllophoneList(allophoneString, true);
StringBuilder pronunciation = new StringBuilder();
for (String a : phones) {
if (pronunciation.length() > 0)
pronunciation.append(" ");
pronunciation.append(a);
}
return pronunciation.toString();
}
/**
* Split allophone string into a list of allophone symbols, preserving all stress and syllable boundaries that may be present
*
* @param allophonesString
* allophonesString
* @return a List of allophone Strings
* @throws IllegalArgumentException
* if allophoneString contains a symbol for which no Allophone can be found
*/
public List<String> splitIntoAllophoneList(String allophonesString) {
return splitIntoAllophoneList(allophonesString, true);
}
/**
* Split allophone string into a list of allophone symbols. Include (or ignore, depending on parameter
* 'includeStressAndSyllableMarkers') stress markers (',), syllable boundaries (-). Ignores space characters.
*
* @param allophoneString
* @param includeStressAndSyllableMarkers
* whether to skip stress markers and syllable boundaries. If true, will return each such marker as a separate
* string in the list.
* @throws IllegalArgumentException
* if the string contains illegal symbols.
* @return a list of allophone strings.
*/
private List<String> splitIntoAllophoneList(String allophoneString, boolean includeStressAndSyllableMarkers) {
List<String> phones = new ArrayList<String>();
for (int i = 0; i < allophoneString.length(); i++) {
String one = allophoneString.substring(i, i + 1);
// Allow modification of ignore characters in allophones.xml
if (getIgnoreChars().contains(one)) {
if (includeStressAndSyllableMarkers)
phones.add(one);
continue;
} else if (one.equals(" ")) {
continue;
}
// Try to cut off individual segments,
// starting with the longest prefixes:
String ph = null;
for (int l = maxAllophoneSymbolLength; l >= 1; l--) {
if (i + l <= allophoneString.length()) {
ph = allophoneString.substring(i, i + l);
// look up in allophone map:
if (allophones.containsKey(ph)) {
// OK, found a symbol of length l.
i += l - 1; // together with the i++ in the for loop, move by l
break;
}
}
}
if (ph != null && allophones.containsKey(ph)) {
// have found a valid phone
phones.add(ph);
} else {
// FIXME: temporarily handle digit suffix stress notation from legacy LTS CARTs until these are rebuilt
String stress = null;
switch (ph) {
case "1":
stress = Stress.PRIMARY;
break;
case "2":
stress = Stress.SECONDARY;
break;
case "0":
stress = Stress.NONE;
break;
}
if (stress != null && phones.size() > 0) {
phones.add(phones.size() - 1, stress);
} else {
throw new IllegalArgumentException("Found unknown symbol `" + allophoneString.charAt(i)
+ "' in phonetic string `" + allophoneString + "' -- ignoring.");
}
}
}
return phones;
}
/**
* Check whether the given allophone string has a correct syntax according to this allophone set.
*
* @param allophoneString
* allophoneString
* @return true if the syntax is correct, false otherwise.
*/
public boolean checkAllophoneSyntax(String allophoneString) {
try {
splitIntoAllophoneList(allophoneString, false);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
/**
* Syllabify a string of allophones. If stress markers are provided, they are preserved; otherwise, primary stress will be
* assigned to the initial syllable.
* <p>
* The syllabification algorithm itself follows the <i>Core Syllabification Principle (CSP)</i> from <blockquote>G.N. Clements
* (1990) "The role of the sonority cycle in core syllabification." In: J. Kingston & M.E. Beckman (Eds.),
* <em>Papers in Laboratory Phonology I: Between the Grammar and Physics of Speech</em>, Ch. 17, pp. 283-333, Cambridge
* University Press.</blockquote>
*
* @param phoneString
* phoneString
* @return a syllabified string; individual allophones are separated by spaces, and syllables, by dashes.
* @throws IllegalArgumentException
* if the <b>phoneString</b> is empty or contains a symbol that satisfies none of the following conditions:
* <ol>
* <li>the symbol corresponds to an Allophone, or</li> <li>the symbol is a stress symbol (cf. {@link Stress}), or
* </li> <li>the symbol is a syllable boundary (<code>-</code>)</li>
* </ol>
*
*/
public String syllabify(String phoneString) throws IllegalArgumentException {
// Before we process, a sanity check:
if (phoneString.trim().isEmpty()) {
throw new IllegalArgumentException("Cannot syllabify empty phone string");
}
// First, split phoneString into a List of allophone Strings...
List<String> allophoneStrings = splitIntoAllophoneList(phoneString, true);
// ...and create from it a List of generic Objects
List<Object> phonesAndSyllables = new ArrayList<Object>(allophoneStrings);
// Create an iterator
ListIterator<Object> iterator = phonesAndSyllables.listIterator();
// First iteration (left-to-right):
// CSP (a): Associate each [+syllabic] segment to a syllable node.
Syllable currentSyllable = null;
while (iterator.hasNext()) {
String phone = (String) iterator.next();
try {
// either it's an Allophone
Allophone allophone = getAllophone(phone);
if (allophone.isSyllabic()) {
// if /6/ immediately follows a non-diphthong vowel, it should be appended instead of forming its own syllable
boolean appendR = false;
if (allophone.getFeature("ctype").equals("r")) {
// it's an /6/
if (iterator.previousIndex() > 1) {
Object previousPhoneOrSyllable = phonesAndSyllables.get(iterator.previousIndex() - 1);
if (previousPhoneOrSyllable == currentSyllable) {
// the /6/ immediately follows the current syllable
if (!currentSyllable.getLastAllophone().isDiphthong()) {
// the vowel immediately preceding the /6/ is not a diphthong
appendR = true;
}
}
}
}
if (appendR) {
iterator.remove();
currentSyllable.appendAllophone(allophone);
} else {
currentSyllable = new Syllable(allophone);
iterator.set(currentSyllable);
}
}
} catch (IllegalArgumentException e) {
// or a stress or boundary marker
if (!getIgnoreChars().contains(phone)) {
throw e;
}
}
}
// Second iteration (right-to-left):
// CSP (b): Given P (an unsyllabified segment) preceding Q (a syllabified segment), adjoin P to the syllable containing Q
// iff P has lower sonority rank than Q (iterative).
currentSyllable = null;
boolean foundPrimaryStress = false;
iterator = phonesAndSyllables.listIterator(phonesAndSyllables.size());
while (iterator.hasPrevious()) {
Object phoneOrSyllable = iterator.previous();
if (phoneOrSyllable instanceof Syllable) {
currentSyllable = (Syllable) phoneOrSyllable;
} else if (currentSyllable == null) {
// haven't seen a Syllable yet in this iteration
continue;
} else {
String phone = (String) phoneOrSyllable;
try {
// it's an Allophone -- prepend to the Syllable
Allophone allophone = getAllophone(phone);
if (allophone.sonority() < currentSyllable.getFirstAllophone().sonority()) {
iterator.remove();
currentSyllable.prependAllophone(allophone);
}
} catch (IllegalArgumentException e) {
// it's a provided stress marker -- assign it to the Syllable
switch (phone) {
case Stress.PRIMARY:
iterator.remove();
currentSyllable.setStress(Stress.PRIMARY);
foundPrimaryStress = true;
break;
case Stress.SECONDARY:
iterator.remove();
currentSyllable.setStress(Stress.SECONDARY);
break;
case "-":
iterator.remove();
// TODO handle syllable boundaries
break;
default:
throw e;
}
}
}
}
// Third iteration (left-to-right):
// CSP (c): Given Q (a syllabified segment) followed by R (an unsyllabified segment), adjoin R to the syllable containing
// Q iff has a lower sonority rank than Q (iterative).
Syllable initialSyllable = currentSyllable;
currentSyllable = null;
iterator = phonesAndSyllables.listIterator();
while (iterator.hasNext()) {
Object phoneOrSyllable = iterator.next();
if (phoneOrSyllable instanceof Syllable) {
currentSyllable = (Syllable) phoneOrSyllable;
} else {
String phone = (String) phoneOrSyllable;
try {
// it's an Allophone -- append to the Syllable
Allophone allophone;
try {
allophone = getAllophone(phone);
} catch (IllegalArgumentException e) {
// or a stress or boundary marker -- remove
if (getIgnoreChars().contains(phone)) {
iterator.remove();
continue;
} else {
throw e;
}
}
if (currentSyllable == null) {
// haven't seen a Syllable yet in this iteration
iterator.remove();
if (initialSyllable == null) {
// haven't seen any syllable at all
initialSyllable = new Syllable(allophone);
iterator.add(initialSyllable);
} else {
initialSyllable.prependAllophone(allophone);
}
} else {
// append it to the last seen Syllable
iterator.remove();
currentSyllable.appendAllophone(allophone);
}
} catch (IllegalArgumentException e) {
throw e;
}
}
}
// if primary stress was not provided, assign it to initial syllable
if (!foundPrimaryStress) {
initialSyllable.setStress(Stress.PRIMARY);
}
// join Syllables with dashes and return the String
return StringUtils.join(phonesAndSyllables, " - ");
}
/**
* Helper class for OO syllabification. Wraps an ArrayList of Allophones and has a Stress property.
*
* @author ingmar
*
*/
private class Syllable {
private List<Allophone> allophones = new ArrayList<Allophone>();
private String stress = Stress.NONE;
public Syllable(Allophone... allophones) {
Collections.addAll(this.allophones, allophones);
}
public Allophone getFirstAllophone() {
return allophones.get(0);
}
public void prependAllophone(Allophone allophone) {
allophones.add(0, allophone);
}
public Allophone getLastAllophone() {
return allophones.get(allophones.size() - 1);
}
public void appendAllophone(Allophone allophone) {
allophones.add(allophone);
}
public void setStress(String stress) {
this.stress = stress;
}
/**
* @return The Stress, if not {@link Stress.NONE NONE}, followed by the Allophones, all separated by spaces
*/
public String toString() {
return String.format("%s %s", stress, StringUtils.join(allophones, " ")).trim();
}
}
/**
* Constants for Stress markers
*
* @author ingmar
*
*/
public interface Stress {
String NONE = "";
String PRIMARY = "'";
String SECONDARY = ",";
}
}