RecognizerEnginepublic class RecognizerEngine extends Object This class knows how to recognize speech. A usage cycle is as follows:
- Create with a reference to the {@link VoiceDialerActivity}.
- Signal the user to start speaking with the Vibrator or beep.
- Start audio input by creating a {@link MicrophoneInputStream}.
- Create and configure a {@link Recognizer}.
- Fetch a List of {@link VoiceContact} and determine if there is a
corresponding g2g file which is up-to-date.
- If the g2g file is out-of-date, update and save it.
- Start the {@link Recognizer} running using data already being
collected by the {@link Microphone}.
- Wait for the {@link Recognizer} to complete.
- Pass a list of {@link Intent} corresponding to the recognition results
to the {@link VoiceDialerActivity}, which notifies the user.
- Shut down and clean up.
Notes:
- Audio many be read from a file.
- A directory tree of audio files may be stepped through.
- A contact list may be read from a file.
- A {@link RecognizerLogger} may generate a set of log files from
a recognition session.
- A static instance of this class is held and reused by the
{@link VoiceDialerActivity}, which saves setup time.
|
Fields Summary |
---|
private static final String | TAG | public static final String | SENTENCE_EXTRA | private final String | SREC_DIR | private static final String | OPEN_ENTRIES | private static final int | RESULT_LIMIT | private static final int | SAMPLE_RATE | private com.android.voicedialer.VoiceDialerActivity | mVoiceDialerActivity | private android.speech.srec.Recognizer | mSrec | private Recognizer.Grammar | mSrecGrammar | private com.android.voicedialer.RecognizerLogger | mLogger | private static final char[] | mLatin1Letters | private static final int | mLatin1Base | private static final String | mNanpFormats | private static final String | mPlusFormats |
Constructors Summary |
---|
public RecognizerEngine()Constructor.
|
Methods Summary |
---|
private static void | addCallIntent(java.util.ArrayList intents, android.net.Uri uri, java.lang.String literal, int flags)
Intent intent = new Intent(Intent.ACTION_CALL_PRIVILEGED, uri).
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | flags).
putExtra(SENTENCE_EXTRA, literal);
addIntent(intents, intent);
| private static void | addClassName(java.util.HashMap openEntries, java.lang.String label, java.lang.String className)Add a className to a hash table of class name lists.
String labelLowerCase = label.toLowerCase();
String classList = openEntries.get(labelLowerCase);
// first item in the list
if (classList == null) {
openEntries.put(labelLowerCase, className);
return;
}
// already in list
int index = classList.indexOf(className);
int after = index + className.length();
if (index != -1 && (index == 0 || classList.charAt(index - 1) == ' ") &&
(after == classList.length() || classList.charAt(after) == ' ")) return;
// add it to the end
openEntries.put(labelLowerCase, classList + ' " + className);
| private static void | addIntent(java.util.ArrayList intents, android.content.Intent intent)
for (Intent in : intents) {
if (in.getAction() != null &&
in.getAction().equals(intent.getAction()) &&
in.getData() != null &&
in.getData().equals(intent.getData())) {
return;
}
}
intents.add(intent);
| private void | addNameEntriesToGrammar(java.util.List contacts)Add a list of names to the grammar
if (Config.LOGD) Log.d(TAG, "addNameEntriesToGrammar " + contacts.size());
HashSet entries = new HashSet<String>();
StringBuffer sb = new StringBuffer();
for (VoiceContact contact : contacts) {
if (Thread.interrupted()) throw new InterruptedException();
String name = scrubName(contact.mName);
if (name.length() == 0 || !entries.add(name)) continue;
sb.setLength(0);
sb.append("V='");
sb.append(contact.mPersonId).append(' ");
sb.append(contact.mPrimaryId).append(' ");
sb.append(contact.mHomeId).append(' ");
sb.append(contact.mMobileId).append(' ");
sb.append(contact.mWorkId).append(' ");
sb.append(contact.mOtherId);
sb.append("'");
mSrecGrammar.addWordToSlot("@Names", name, null, 1, sb.toString());
}
| private void | addOpenEntriesToGrammar()add a list of application labels to the 'open x' grammar
if (Config.LOGD) Log.d(TAG, "addOpenEntriesToGrammar");
// fill this
HashMap<String, String> openEntries;
File oe = mVoiceDialerActivity.getFileStreamPath(OPEN_ENTRIES);
// build and write list of entries
if (!oe.exists()) {
openEntries = new HashMap<String, String>();
// build a list of 'open' entries
PackageManager pm = mVoiceDialerActivity.getPackageManager();
List<ResolveInfo> riList = pm.queryIntentActivities(
new Intent(Intent.ACTION_MAIN).
addCategory("android.intent.category.VOICE_LAUNCH"),
PackageManager.GET_ACTIVITIES);
if (Thread.interrupted()) throw new InterruptedException();
riList.addAll(pm.queryIntentActivities(
new Intent(Intent.ACTION_MAIN).
addCategory("android.intent.category.LAUNCHER"),
PackageManager.GET_ACTIVITIES));
String voiceDialerClassName = mVoiceDialerActivity.getComponentName().getClassName();
// scan list, adding complete phrases, as well as individual words
for (ResolveInfo ri : riList) {
if (Thread.interrupted()) throw new InterruptedException();
// skip self
if (voiceDialerClassName.equals(ri.activityInfo.name)) continue;
// fetch a scrubbed window label
String label = scrubName(ri.loadLabel(pm).toString());
if (label.length() == 0) continue;
// insert it into the result list
addClassName(openEntries, label, ri.activityInfo.name);
// split it into individual words, and insert them
String[] words = label.split(" ");
if (words.length > 1) {
for (String word : words) {
word = word.trim();
// words must be three characters long, or two if capitalized
int len = word.length();
if (len <= 1) continue;
if (len == 2 && !(Character.isUpperCase(word.charAt(0)) &&
Character.isUpperCase(word.charAt(1)))) continue;
if ("and".equalsIgnoreCase(word) || "the".equalsIgnoreCase(word)) continue;
// add the word
addClassName(openEntries, word, ri.activityInfo.name);
}
}
}
// write list
if (Config.LOGD) Log.d(TAG, "addOpenEntriesToGrammar writing " + oe);
try {
FileOutputStream fos = new FileOutputStream(oe);
try {
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(openEntries);
oos.close();
} finally {
fos.close();
}
} catch (IOException ioe) {
deleteCachedGrammarFiles(mVoiceDialerActivity);
throw ioe;
}
}
// read the list
else {
if (Config.LOGD) Log.d(TAG, "addOpenEntriesToGrammar reading " + oe);
try {
FileInputStream fis = new FileInputStream(oe);
try {
ObjectInputStream ois = new ObjectInputStream(fis);
openEntries = (HashMap<String, String>)ois.readObject();
ois.close();
} finally {
fis.close();
}
} catch (Exception e) {
deleteCachedGrammarFiles(mVoiceDialerActivity);
throw new IOException(e.toString());
}
}
// add list of 'open' entries to the grammar
for (String label : openEntries.keySet()) {
if (Thread.interrupted()) throw new InterruptedException();
String entry = openEntries.get(label);
// don't add if too many results
int count = 0;
for (int i = 0; 0 != (i = entry.indexOf(' ", i) + 1); count++) ;
if (count > RESULT_LIMIT) continue;
// add the word to the grammar
mSrecGrammar.addWordToSlot("@Opens", label, null, 1, "V='" + entry + "'");
}
| private static void | deleteAllG2GFiles(android.content.Context context)Delete all g2g files in the directory indicated by {@link File},
which is typically /data/data/com.android.voicedialer/files.
There should only be one g2g file at any one time, with a hashcode
embedded in it's name, but if stale ones are present, this will delete
them all.
FileFilter ff = new FileFilter() {
public boolean accept(File f) {
String name = f.getName();
return name.endsWith(".g2g");
}
};
File[] files = context.getFilesDir().listFiles(ff);
if (files != null) {
for (File file : files) {
if (Config.LOGD) Log.d(TAG, "deleteAllG2GFiles " + file);
file.delete();
}
}
| public static void | deleteCachedGrammarFiles(android.content.Context context)Delete G2G and OpenEntries files, to force regeneration of the g2g file
from scratch.
deleteAllG2GFiles(context);
File oe = context.getFileStreamPath(OPEN_ENTRIES);
if (Config.LOGD) Log.d(TAG, "deleteCachedGrammarFiles " + oe);
if (oe.exists()) oe.delete();
| private static java.lang.String | formatNumber(java.lang.String num)Format a phone number string.
At some point, PhoneNumberUtils.formatNumber will handle this.
String fmt = null;
fmt = formatNumber(mPlusFormats, num);
if (fmt != null) return fmt;
fmt = formatNumber(mNanpFormats, num);
if (fmt != null) return fmt;
return null;
| private static java.lang.String | formatNumber(java.lang.String formats, java.lang.String number) // Uzbekistan
// TODO: need to handle variable number notation
number = number.trim();
final int nlen = number.length();
final int formatslen = formats.length();
StringBuffer sb = new StringBuffer();
// loop over country codes
for (int f = 0; f < formatslen; ) {
sb.setLength(0);
int n = 0;
// loop over letters of pattern
while (true) {
final char fch = formats.charAt(f);
if (fch == '\n" && n >= nlen) return sb.toString();
if (fch == '\n" || n >= nlen) break;
final char nch = number.charAt(n);
// pattern matches number
if (fch == nch || (fch == 'x" && Character.isDigit(nch))) {
f++;
n++;
sb.append(nch);
}
// don't match ' ' in pattern, but insert into result
else if (fch == ' ") {
f++;
sb.append(' ");
// ' ' at end -> match all the rest
if (formats.charAt(f) == '\n") return sb.append(number, n, nlen).toString();
}
// match failed
else break;
}
// step to the next pattern
f = formats.indexOf('\n", f) + 1;
if (f == 0) break;
}
return null;
| private void | onRecognitionSuccess()Called when recognition succeeds. It receives a list
of results, builds a corresponding list of Intents, and
passes them to the {@link VoiceDialerActivity}, which selects and
performs a corresponding action.
if (Config.LOGD) Log.d(TAG, "onRecognitionSuccess");
if (mLogger != null) mLogger.logNbestHeader();
ArrayList<Intent> intents = new ArrayList<Intent>();
// loop over results
for (int result = 0; result < mSrec.getResultCount() &&
intents.size() < RESULT_LIMIT; result++) {
// parse the semanticMeaning string and build an Intent
String conf = mSrec.getResult(result, Recognizer.KEY_CONFIDENCE);
String literal = mSrec.getResult(result, Recognizer.KEY_LITERAL);
String semantic = mSrec.getResult(result, Recognizer.KEY_MEANING);
String msg = "conf=" + conf + " lit=" + literal + " sem=" + semantic;
if (Config.LOGD) Log.d(TAG, msg);
if (mLogger != null) mLogger.logLine(msg);
String[] commands = semantic.trim().split(" ");
// DIAL 650 867 5309
// DIAL 867 5309
// DIAL 911
if ("DIAL".equals(commands[0])) {
Uri uri = Uri.fromParts("tel", commands[1], null);
String num = formatNumber(commands[1]);
if (num != null) {
addCallIntent(intents, uri,
literal.split(" ")[0].trim() + " " + num, 0);
}
}
// CALL JACK JONES
else if ("CALL".equals(commands[0]) && commands.length >= 7) {
// parse the ids
long personId = Long.parseLong(commands[1]); // people table
long phoneId = Long.parseLong(commands[2]); // phones table
long homeId = Long.parseLong(commands[3]); // phones table
long mobileId = Long.parseLong(commands[4]); // phones table
long workId = Long.parseLong(commands[5]); // phones table
long otherId = Long.parseLong(commands[6]); // phones table
Resources res = mVoiceDialerActivity.getResources();
int count = 0;
//
// generate the best entry corresponding to what was said
//
// 'CALL JACK JONES AT HOME|MOBILE|WORK|OTHER'
if (commands.length == 8) {
long spokenPhoneId =
"H".equalsIgnoreCase(commands[7]) ? homeId :
"M".equalsIgnoreCase(commands[7]) ? mobileId :
"W".equalsIgnoreCase(commands[7]) ? workId :
"O".equalsIgnoreCase(commands[7]) ? otherId :
VoiceContact.ID_UNDEFINED;
if (spokenPhoneId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, spokenPhoneId),
literal, 0);
count++;
}
}
// 'CALL JACK JONES', with valid default phoneId
else if (commands.length == 7) {
CharSequence phoneIdMsg =
phoneId == VoiceContact.ID_UNDEFINED ? null :
phoneId == homeId ? res.getText(R.string.at_home) :
phoneId == mobileId ? res.getText(R.string.on_mobile) :
phoneId == workId ? res.getText(R.string.at_work) :
phoneId == otherId ? res.getText(R.string.at_other) :
null;
if (phoneIdMsg != null) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, phoneId),
literal + phoneIdMsg, 0);
count++;
}
}
//
// generate all other entries
//
// trim last two words, ie 'at home', etc
String lit = literal;
if (commands.length == 8) {
String[] words = literal.trim().split(" ");
StringBuffer sb = new StringBuffer();
for (int i = 0; i < words.length - 2; i++) {
if (i != 0) {
sb.append(' ");
}
sb.append(words[i]);
}
lit = sb.toString();
}
// add 'CALL JACK JONES at home' using phoneId
if (homeId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, homeId),
lit + res.getText(R.string.at_home), 0);
count++;
}
// add 'CALL JACK JONES on mobile' using mobileId
if (mobileId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, mobileId),
lit + res.getText(R.string.on_mobile), 0);
count++;
}
// add 'CALL JACK JONES at work' using workId
if (workId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, workId),
lit + res.getText(R.string.at_work), 0);
count++;
}
// add 'CALL JACK JONES at other' using otherId
if (otherId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.Phones.CONTENT_URI, otherId),
lit + res.getText(R.string.at_other), 0);
count++;
}
//
// if no other entries were generated, use the personId
//
// add 'CALL JACK JONES', with valid personId
if (count == 0 && personId != VoiceContact.ID_UNDEFINED) {
addCallIntent(intents, ContentUris.withAppendedId(
Contacts.People.CONTENT_URI, personId), literal, 0);
}
}
// "CALL VoiceMail"
else if ("voicemail".equals(commands[0]) && commands.length == 1) {
addCallIntent(intents, Uri.fromParts("voicemail", "x", null),
literal, Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
}
// "REDIAL"
else if ("redial".equals(commands[0]) && commands.length == 1) {
String number = VoiceContact.redialNumber(mVoiceDialerActivity);
if (number != null) {
addCallIntent(intents, Uri.fromParts("tel", number, null), literal,
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
}
}
// "Intent ..."
else if ("Intent".equalsIgnoreCase(commands[0])) {
for (int i = 1; i < commands.length; i++) {
try {
Intent intent = Intent.getIntent(commands[i]);
if (intent.getStringExtra(SENTENCE_EXTRA) == null) {
intent.putExtra(SENTENCE_EXTRA, literal);
}
addIntent(intents, intent);
} catch (URISyntaxException e) {
if (Config.LOGD) Log.d(TAG,
"onRecognitionSuccess: poorly formed URI in grammar\n" + e);
}
}
}
// "OPEN ..."
else if ("OPEN".equals(commands[0])) {
PackageManager pm = mVoiceDialerActivity.getPackageManager();
for (int i = 1; i < commands.length; i++) {
String cn = commands[i];
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory("android.intent.category.VOICE_LAUNCH");
intent.setClassName(cn.substring(0, cn.lastIndexOf('.")), cn);
List<ResolveInfo> riList = pm.queryIntentActivities(intent, 0);
for (ResolveInfo ri : riList) {
String label = ri.loadLabel(pm).toString();
intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory("android.intent.category.VOICE_LAUNCH");
intent.setClassName(cn.substring(0, cn.lastIndexOf('.")), cn);
intent.putExtra(SENTENCE_EXTRA, literal.split(" ")[0] + " " + label);
addIntent(intents, intent);
}
}
}
// can't parse result
else {
if (Config.LOGD) Log.d(TAG, "onRecognitionSuccess: parse error");
}
}
// log if requested
if (mLogger != null) mLogger.logIntents(intents);
// bail out if cancelled
if (Thread.interrupted()) throw new InterruptedException();
if (intents.size() == 0) {
// TODO: strip HOME|MOBILE|WORK and try default here?
mVoiceDialerActivity.onRecognitionFailure("No Intents generated");
}
else {
mVoiceDialerActivity.onRecognitionSuccess(
intents.toArray(new Intent[intents.size()]));
}
| public void | recognize(com.android.voicedialer.VoiceDialerActivity voiceDialerActivity, java.io.File micFile, java.io.File contactsFile, java.lang.String codec)Start the recognition process.
- Create and start the microphone.
- Create a Recognizer.
- Scan contacts and determine if the Grammar g2g file is stale.
- If so, create and rebuild the Grammar,
- Else create and load the Grammar from the file.
- Start the Recognizer.
- Feed the Recognizer audio until it provides a result.
- Build a list of Intents corresponding to the results.
- Stop the microphone.
- Stop the Recognizer.
InputStream mic = null;
boolean recognizerStarted = false;
try {
if (Config.LOGD) Log.d(TAG, "start");
mVoiceDialerActivity = voiceDialerActivity;
// set up logger
mLogger = null;
if (RecognizerLogger.isEnabled(mVoiceDialerActivity)) {
mLogger = new RecognizerLogger(mVoiceDialerActivity);
}
// start audio input
if (Config.LOGD) Log.d(TAG, "start new MicrophoneInputStream");
if (micFile != null) {
mic = new FileInputStream(micFile);
WaveHeader hdr = new WaveHeader();
hdr.read(mic);
} else {
mic = new MicrophoneInputStream(SAMPLE_RATE, SAMPLE_RATE * 15);
}
// notify UI
voiceDialerActivity.onMicrophoneStart();
// create a new recognizer
if (Config.LOGD) Log.d(TAG, "start new Recognizer");
if (mSrec == null) mSrec = new Recognizer(SREC_DIR + "/baseline11k.par");
// fetch the contact list
if (Config.LOGD) Log.d(TAG, "start getVoiceContacts");
List<VoiceContact> contacts = contactsFile != null ?
VoiceContact.getVoiceContactsFromFile(contactsFile) :
VoiceContact.getVoiceContacts(mVoiceDialerActivity);
// log contacts if requested
if (mLogger != null) mLogger.logContacts(contacts);
// generate g2g grammar file name
File g2g = mVoiceDialerActivity.getFileStreamPath("voicedialer." +
Integer.toHexString(contacts.hashCode()) + ".g2g");
// rebuild g2g file if current one is out of date
if (!g2g.exists()) {
// clean up existing Grammar and old file
deleteAllG2GFiles(mVoiceDialerActivity);
if (mSrecGrammar != null) {
mSrecGrammar.destroy();
mSrecGrammar = null;
}
// load the empty Grammar
if (Config.LOGD) Log.d(TAG, "start new Grammar");
mSrecGrammar = mSrec.new Grammar(SREC_DIR + "/grammars/VoiceDialer.g2g");
mSrecGrammar.setupRecognizer();
// reset slots
if (Config.LOGD) Log.d(TAG, "start grammar.resetAllSlots");
mSrecGrammar.resetAllSlots();
// add names to the grammar
addNameEntriesToGrammar(contacts);
// add 'open' entries to the grammar
addOpenEntriesToGrammar();
// compile the grammar
if (Config.LOGD) Log.d(TAG, "start grammar.compile");
mSrecGrammar.compile();
// update g2g file
if (Config.LOGD) Log.d(TAG, "start grammar.save " + g2g.getPath());
g2g.getParentFile().mkdirs();
mSrecGrammar.save(g2g.getPath());
}
// g2g file exists, but is not loaded
else if (mSrecGrammar == null) {
if (Config.LOGD) Log.d(TAG, "start new Grammar loading " + g2g);
mSrecGrammar = mSrec.new Grammar(g2g.getPath());
mSrecGrammar.setupRecognizer();
}
// start the recognition process
if (Config.LOGD) Log.d(TAG, "start mSrec.start");
mSrec.start();
recognizerStarted = true;
// log audio if requested
if (mLogger != null) mic = mLogger.logInputStream(mic, SAMPLE_RATE);
// recognize
while (true) {
if (Thread.interrupted()) throw new InterruptedException();
int event = mSrec.advance();
if (event != Recognizer.EVENT_INCOMPLETE &&
event != Recognizer.EVENT_NEED_MORE_AUDIO) {
if (Config.LOGD) Log.d(TAG, "start advance()=" +
Recognizer.eventToString(event));
}
switch (event) {
case Recognizer.EVENT_INCOMPLETE:
case Recognizer.EVENT_STARTED:
case Recognizer.EVENT_START_OF_VOICING:
case Recognizer.EVENT_END_OF_VOICING:
continue;
case Recognizer.EVENT_RECOGNITION_RESULT:
onRecognitionSuccess();
break;
case Recognizer.EVENT_NEED_MORE_AUDIO:
mSrec.putAudio(mic);
continue;
default:
mVoiceDialerActivity.onRecognitionFailure(Recognizer.eventToString(event));
break;
}
break;
}
} catch (InterruptedException e) {
if (Config.LOGD) Log.d(TAG, "start interrupted " + e);
} catch (IOException e) {
if (Config.LOGD) Log.d(TAG, "start new Srec failed " + e);
mVoiceDialerActivity.onRecognitionError(e.toString());
} finally {
// stop microphone
try {
if (mic != null) mic.close();
}
catch (IOException ex) {
if (Config.LOGD) Log.d(TAG, "start - mic.close failed - " + ex);
}
mic = null;
// shut down recognizer
if (Config.LOGD) Log.d(TAG, "start mSrec.stop");
if (mSrec != null && recognizerStarted) mSrec.stop();
// close logger
try {
if (mLogger != null) mLogger.close();
}
catch (IOException ex) {
if (Config.LOGD) Log.d(TAG, "start - mLoggger.close failed - " + ex);
}
mLogger = null;
}
if (Config.LOGD) Log.d(TAG, "start bye");
| private static java.lang.String | scrubName(java.lang.String name)Reformat a raw name from the contact list into a form a
{@link SrecEmbeddedGrammar} can digest.
// replace '&' with ' and '
name = name.replace("&", " and ");
// replace '@' with ' at '
name = name.replace("@", " at ");
// remove '(...)'
while (true) {
int i = name.indexOf('(");
if (i == -1) break;
int j = name.indexOf(')", i);
if (j == -1) break;
name = name.substring(0, i) + " " + name.substring(j + 1);
}
// map letters of Latin1 Supplement to basic ascii
char[] nm = null;
for (int i = name.length() - 1; i >= 0; i--) {
char ch = name.charAt(i);
if (ch < ' " || '~" < ch) {
if (nm == null) nm = name.toCharArray();
nm[i] = mLatin1Base <= ch && ch < mLatin1Base + mLatin1Letters.length ?
mLatin1Letters[ch - mLatin1Base] : ' ";
}
}
if (nm != null) {
name = new String(nm);
}
// if '.' followed by alnum, replace with ' dot '
while (true) {
int i = name.indexOf('.");
if (i == -1 ||
i + 1 >= name.length() ||
!Character.isLetterOrDigit(name.charAt(i + 1))) break;
name = name.substring(0, i) + " dot " + name.substring(i + 1);
}
// trim
name = name.trim();
// ensure at least one alphanumeric character, or the pron engine will fail
for (int i = name.length() - 1; true; i--) {
if (i < 0) return "";
char ch = name.charAt(i);
if (('a" <= ch && ch <= 'z") || ('A" <= ch && ch <= 'Z") || ('0" <= ch && ch <= '9")) {
break;
}
}
return name;
|
|