marytts.modules.phonemiser.AllophoneSet.java Source code

Java tutorial

Introduction

Here is the source code for marytts.modules.phonemiser.AllophoneSet.java

Source

/**
 * 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 &amp; 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 = ",";
    }
}