TtsService.java revision 28dbae7df43ee683ba1bf468ad9924092bb9c569
1/* 2 * Copyright (C) 2009 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16package android.tts; 17 18import android.app.Service; 19import android.content.ContentResolver; 20import android.content.Context; 21import android.content.Intent; 22import android.content.SharedPreferences; 23import android.content.pm.PackageManager; 24import android.content.pm.PackageManager.NameNotFoundException; 25import android.media.MediaPlayer; 26import android.media.MediaPlayer.OnCompletionListener; 27import android.net.Uri; 28import android.os.IBinder; 29import android.os.RemoteCallbackList; 30import android.os.RemoteException; 31import android.preference.PreferenceManager; 32import android.speech.tts.ITts.Stub; 33import android.speech.tts.ITtsCallback; 34import android.speech.tts.TextToSpeech; 35import android.util.Log; 36import java.util.ArrayList; 37import java.util.Arrays; 38import java.util.HashMap; 39import java.util.concurrent.locks.ReentrantLock; 40 41/** 42 * @hide Synthesizes speech from text. This is implemented as a service so that 43 * other applications can call the TTS without needing to bundle the TTS 44 * in the build. 45 * 46 */ 47public class TtsService extends Service implements OnCompletionListener { 48 49 private static class SpeechItem { 50 public static final int SPEECH = 0; 51 public static final int EARCON = 1; 52 public static final int SILENCE = 2; 53 public String mText = null; 54 public ArrayList<String> mParams = null; 55 public int mType = SPEECH; 56 public long mDuration = 0; 57 58 public SpeechItem(String text, ArrayList<String> params, int itemType) { 59 mText = text; 60 mParams = params; 61 mType = itemType; 62 } 63 64 public SpeechItem(long silenceTime) { 65 mDuration = silenceTime; 66 } 67 } 68 69 /** 70 * Contains the information needed to access a sound resource; the name of 71 * the package that contains the resource and the resID of the resource 72 * within that package. 73 */ 74 private static class SoundResource { 75 public String mSourcePackageName = null; 76 public int mResId = -1; 77 public String mFilename = null; 78 79 public SoundResource(String packageName, int id) { 80 mSourcePackageName = packageName; 81 mResId = id; 82 mFilename = null; 83 } 84 85 public SoundResource(String file) { 86 mSourcePackageName = null; 87 mResId = -1; 88 mFilename = file; 89 } 90 } 91 92 private static final String ACTION = "android.intent.action.USE_TTS"; 93 private static final String CATEGORY = "android.intent.category.TTS"; 94 private static final String PKGNAME = "android.tts"; 95 96 final RemoteCallbackList<android.speech.tts.ITtsCallback> mCallbacks = new RemoteCallbackList<ITtsCallback>(); 97 98 private Boolean mIsSpeaking; 99 private ArrayList<SpeechItem> mSpeechQueue; 100 private HashMap<String, SoundResource> mEarcons; 101 private HashMap<String, SoundResource> mUtterances; 102 private MediaPlayer mPlayer; 103 private TtsService mSelf; 104 105 private ContentResolver mResolver; 106 107 private final ReentrantLock speechQueueLock = new ReentrantLock(); 108 private final ReentrantLock synthesizerLock = new ReentrantLock(); 109 110 private SynthProxy nativeSynth; 111 112 @Override 113 public void onCreate() { 114 super.onCreate(); 115 Log.i("TTS", "TTS starting"); 116 117 mResolver = getContentResolver(); 118 119 String soLibPath = "/system/lib/libttspico.so"; 120 nativeSynth = new SynthProxy(soLibPath); 121 122 mSelf = this; 123 mIsSpeaking = false; 124 125 mEarcons = new HashMap<String, SoundResource>(); 126 mUtterances = new HashMap<String, SoundResource>(); 127 128 mSpeechQueue = new ArrayList<SpeechItem>(); 129 mPlayer = null; 130 131 setDefaultSettings(); 132 } 133 134 @Override 135 public void onDestroy() { 136 super.onDestroy(); 137 // Don't hog the media player 138 cleanUpPlayer(); 139 140 nativeSynth.shutdown(); 141 142 // Unregister all callbacks. 143 mCallbacks.kill(); 144 } 145 146 147 private void setDefaultSettings() { 148 149 // TODO handle default language 150 setLanguage("eng", "USA", ""); 151 152 // speech rate 153 setSpeechRate(getDefaultRate()); 154 155 } 156 157 158 private boolean isDefaultEnforced() { 159 return (android.provider.Settings.Secure.getInt(mResolver, 160 android.provider.Settings.Secure.TTS_USE_DEFAULTS, 161 TextToSpeech.Engine.FALLBACK_TTS_USE_DEFAULTS) 162 == 1 ); 163 } 164 165 166 private int getDefaultRate() { 167 return android.provider.Settings.Secure.getInt(mResolver, 168 android.provider.Settings.Secure.TTS_DEFAULT_RATE, 169 TextToSpeech.Engine.FALLBACK_TTS_DEFAULT_RATE); 170 } 171 172 173 private String getDefaultLanguage() { 174 String defaultLang = android.provider.Settings.Secure.getString(mResolver, 175 android.provider.Settings.Secure.TTS_DEFAULT_LANG); 176 if (defaultLang == null) { 177 return TextToSpeech.Engine.FALLBACK_TTS_DEFAULT_LANG; 178 } else { 179 return defaultLang; 180 } 181 } 182 183 184 private String getDefaultCountry() { 185 String defaultCountry = android.provider.Settings.Secure.getString(mResolver, 186 android.provider.Settings.Secure.TTS_DEFAULT_COUNTRY); 187 if (defaultCountry == null) { 188 return TextToSpeech.Engine.FALLBACK_TTS_DEFAULT_COUNTRY; 189 } else { 190 return defaultCountry; 191 } 192 } 193 194 195 private String getDefaultLocVariant() { 196 String defaultVar = android.provider.Settings.Secure.getString(mResolver, 197 android.provider.Settings.Secure.TTS_DEFAULT_VARIANT); 198 if (defaultVar == null) { 199 return TextToSpeech.Engine.FALLBACK_TTS_DEFAULT_VARIANT; 200 } else { 201 return defaultVar; 202 } 203 } 204 205 206 private void setSpeechRate(int rate) { 207 if (isDefaultEnforced()) { 208 nativeSynth.setSpeechRate(getDefaultRate()); 209 } else { 210 nativeSynth.setSpeechRate(rate); 211 } 212 } 213 214 215 private void setPitch(int pitch) { 216 nativeSynth.setPitch(pitch); 217 } 218 219 220 private void setLanguage(String lang, String country, String variant) { 221 Log.v("TTS", "TtsService.setLanguage(" + lang + ", " + country + ", " + variant + ")"); 222 if (isDefaultEnforced()) { 223 nativeSynth.setLanguage(getDefaultLanguage(), getDefaultCountry(), 224 getDefaultLocVariant()); 225 } else { 226 nativeSynth.setLanguage(lang, country, variant); 227 } 228 } 229 230 231 /** 232 * Adds a sound resource to the TTS. 233 * 234 * @param text 235 * The text that should be associated with the sound resource 236 * @param packageName 237 * The name of the package which has the sound resource 238 * @param resId 239 * The resource ID of the sound within its package 240 */ 241 private void addSpeech(String text, String packageName, int resId) { 242 mUtterances.put(text, new SoundResource(packageName, resId)); 243 } 244 245 /** 246 * Adds a sound resource to the TTS. 247 * 248 * @param text 249 * The text that should be associated with the sound resource 250 * @param filename 251 * The filename of the sound resource. This must be a complete 252 * path like: (/sdcard/mysounds/mysoundbite.mp3). 253 */ 254 private void addSpeech(String text, String filename) { 255 mUtterances.put(text, new SoundResource(filename)); 256 } 257 258 /** 259 * Adds a sound resource to the TTS as an earcon. 260 * 261 * @param earcon 262 * The text that should be associated with the sound resource 263 * @param packageName 264 * The name of the package which has the sound resource 265 * @param resId 266 * The resource ID of the sound within its package 267 */ 268 private void addEarcon(String earcon, String packageName, int resId) { 269 mEarcons.put(earcon, new SoundResource(packageName, resId)); 270 } 271 272 /** 273 * Adds a sound resource to the TTS as an earcon. 274 * 275 * @param earcon 276 * The text that should be associated with the sound resource 277 * @param filename 278 * The filename of the sound resource. This must be a complete 279 * path like: (/sdcard/mysounds/mysoundbite.mp3). 280 */ 281 private void addEarcon(String earcon, String filename) { 282 mEarcons.put(earcon, new SoundResource(filename)); 283 } 284 285 /** 286 * Speaks the given text using the specified queueing mode and parameters. 287 * 288 * @param text 289 * The text that should be spoken 290 * @param queueMode 291 * 0 for no queue (interrupts all previous utterances), 1 for 292 * queued 293 * @param params 294 * An ArrayList of parameters. This is not implemented for all 295 * engines. 296 */ 297 private void speak(String text, int queueMode, ArrayList<String> params) { 298 if (queueMode == 0) { 299 stop(); 300 } 301 mSpeechQueue.add(new SpeechItem(text, params, SpeechItem.SPEECH)); 302 if (!mIsSpeaking) { 303 processSpeechQueue(); 304 } 305 } 306 307 /** 308 * Plays the earcon using the specified queueing mode and parameters. 309 * 310 * @param earcon 311 * The earcon that should be played 312 * @param queueMode 313 * 0 for no queue (interrupts all previous utterances), 1 for 314 * queued 315 * @param params 316 * An ArrayList of parameters. This is not implemented for all 317 * engines. 318 */ 319 private void playEarcon(String earcon, int queueMode, 320 ArrayList<String> params) { 321 if (queueMode == 0) { 322 stop(); 323 } 324 mSpeechQueue.add(new SpeechItem(earcon, params, SpeechItem.EARCON)); 325 if (!mIsSpeaking) { 326 processSpeechQueue(); 327 } 328 } 329 330 /** 331 * Stops all speech output and removes any utterances still in the queue. 332 */ 333 private void stop() { 334 Log.i("TTS", "Stopping"); 335 mSpeechQueue.clear(); 336 337 nativeSynth.stop(); 338 mIsSpeaking = false; 339 if (mPlayer != null) { 340 try { 341 mPlayer.stop(); 342 } catch (IllegalStateException e) { 343 // Do nothing, the player is already stopped. 344 } 345 } 346 Log.i("TTS", "Stopped"); 347 } 348 349 public void onCompletion(MediaPlayer arg0) { 350 processSpeechQueue(); 351 } 352 353 private void playSilence(long duration, int queueMode, 354 ArrayList<String> params) { 355 if (queueMode == 0) { 356 stop(); 357 } 358 mSpeechQueue.add(new SpeechItem(duration)); 359 if (!mIsSpeaking) { 360 processSpeechQueue(); 361 } 362 } 363 364 private void silence(final long duration) { 365 class SilenceThread implements Runnable { 366 public void run() { 367 try { 368 Thread.sleep(duration); 369 } catch (InterruptedException e) { 370 e.printStackTrace(); 371 } finally { 372 processSpeechQueue(); 373 } 374 } 375 } 376 Thread slnc = (new Thread(new SilenceThread())); 377 slnc.setPriority(Thread.MIN_PRIORITY); 378 slnc.start(); 379 } 380 381 private void speakInternalOnly(final String text, 382 final ArrayList<String> params) { 383 class SynthThread implements Runnable { 384 public void run() { 385 boolean synthAvailable = false; 386 try { 387 synthAvailable = synthesizerLock.tryLock(); 388 if (!synthAvailable) { 389 Thread.sleep(100); 390 Thread synth = (new Thread(new SynthThread())); 391 synth.setPriority(Thread.MIN_PRIORITY); 392 synth.start(); 393 return; 394 } 395 nativeSynth.speak(text); 396 } catch (InterruptedException e) { 397 e.printStackTrace(); 398 } finally { 399 // This check is needed because finally will always run; 400 // even if the 401 // method returns somewhere in the try block. 402 if (synthAvailable) { 403 synthesizerLock.unlock(); 404 } 405 processSpeechQueue(); 406 } 407 } 408 } 409 Thread synth = (new Thread(new SynthThread())); 410 synth.setPriority(Thread.MIN_PRIORITY); 411 synth.start(); 412 } 413 414 private SoundResource getSoundResource(SpeechItem speechItem) { 415 SoundResource sr = null; 416 String text = speechItem.mText; 417 if (speechItem.mType == SpeechItem.SILENCE) { 418 // Do nothing if this is just silence 419 } else if (speechItem.mType == SpeechItem.EARCON) { 420 sr = mEarcons.get(text); 421 } else { 422 sr = mUtterances.get(text); 423 } 424 return sr; 425 } 426 427 private void broadcastTtsQueueProcessingCompleted(){ 428 Intent i = new Intent(Intent.ACTION_TTS_QUEUE_PROCESSING_COMPLETED); 429 sendBroadcast(i); 430 } 431 432 private void dispatchSpeechCompletedCallbacks(String mark) { 433 Log.i("TTS callback", "dispatch started"); 434 // Broadcast to all clients the new value. 435 final int N = mCallbacks.beginBroadcast(); 436 for (int i = 0; i < N; i++) { 437 try { 438 mCallbacks.getBroadcastItem(i).markReached(mark); 439 } catch (RemoteException e) { 440 // The RemoteCallbackList will take care of removing 441 // the dead object for us. 442 } 443 } 444 mCallbacks.finishBroadcast(); 445 Log.i("TTS callback", "dispatch completed to " + N); 446 } 447 448 private void processSpeechQueue() { 449 boolean speechQueueAvailable = false; 450 try { 451 speechQueueAvailable = speechQueueLock.tryLock(); 452 if (!speechQueueAvailable) { 453 return; 454 } 455 if (mSpeechQueue.size() < 1) { 456 mIsSpeaking = false; 457 broadcastTtsQueueProcessingCompleted(); 458 return; 459 } 460 461 SpeechItem currentSpeechItem = mSpeechQueue.get(0); 462 mIsSpeaking = true; 463 SoundResource sr = getSoundResource(currentSpeechItem); 464 // Synth speech as needed - synthesizer should call 465 // processSpeechQueue to continue running the queue 466 Log.i("TTS processing: ", currentSpeechItem.mText); 467 if (sr == null) { 468 if (currentSpeechItem.mType == SpeechItem.SPEECH) { 469 // TODO: Split text up into smaller chunks before accepting 470 // them for processing. 471 speakInternalOnly(currentSpeechItem.mText, 472 currentSpeechItem.mParams); 473 } else { 474 // This is either silence or an earcon that was missing 475 silence(currentSpeechItem.mDuration); 476 } 477 } else { 478 cleanUpPlayer(); 479 if (sr.mSourcePackageName == PKGNAME) { 480 // Utterance is part of the TTS library 481 mPlayer = MediaPlayer.create(this, sr.mResId); 482 } else if (sr.mSourcePackageName != null) { 483 // Utterance is part of the app calling the library 484 Context ctx; 485 try { 486 ctx = this.createPackageContext(sr.mSourcePackageName, 487 0); 488 } catch (NameNotFoundException e) { 489 e.printStackTrace(); 490 mSpeechQueue.remove(0); // Remove it from the queue and 491 // move on 492 mIsSpeaking = false; 493 return; 494 } 495 mPlayer = MediaPlayer.create(ctx, sr.mResId); 496 } else { 497 // Utterance is coming from a file 498 mPlayer = MediaPlayer.create(this, Uri.parse(sr.mFilename)); 499 } 500 501 // Check if Media Server is dead; if it is, clear the queue and 502 // give up for now - hopefully, it will recover itself. 503 if (mPlayer == null) { 504 mSpeechQueue.clear(); 505 mIsSpeaking = false; 506 return; 507 } 508 mPlayer.setOnCompletionListener(this); 509 try { 510 mPlayer.start(); 511 } catch (IllegalStateException e) { 512 mSpeechQueue.clear(); 513 mIsSpeaking = false; 514 cleanUpPlayer(); 515 return; 516 } 517 } 518 if (mSpeechQueue.size() > 0) { 519 mSpeechQueue.remove(0); 520 } 521 } finally { 522 // This check is needed because finally will always run; even if the 523 // method returns somewhere in the try block. 524 if (speechQueueAvailable) { 525 speechQueueLock.unlock(); 526 } 527 } 528 } 529 530 private void cleanUpPlayer() { 531 if (mPlayer != null) { 532 mPlayer.release(); 533 mPlayer = null; 534 } 535 } 536 537 /** 538 * Synthesizes the given text using the specified queuing mode and 539 * parameters. 540 * 541 * @param text 542 * The String of text that should be synthesized 543 * @param params 544 * An ArrayList of parameters. The first element of this array 545 * controls the type of voice to use. 546 * @param filename 547 * The string that gives the full output filename; it should be 548 * something like "/sdcard/myappsounds/mysound.wav". 549 * @return A boolean that indicates if the synthesis succeeded 550 */ 551 private boolean synthesizeToFile(String text, ArrayList<String> params, 552 String filename, boolean calledFromApi) { 553 // Only stop everything if this is a call made by an outside app trying 554 // to 555 // use the API. Do NOT stop if this is a call from within the service as 556 // clearing the speech queue here would be a mistake. 557 if (calledFromApi) { 558 stop(); 559 } 560 Log.i("TTS", "Synthesizing to " + filename); 561 boolean synthAvailable = false; 562 try { 563 synthAvailable = synthesizerLock.tryLock(); 564 if (!synthAvailable) { 565 return false; 566 } 567 // Don't allow a filename that is too long 568 // TODO use platform constant 569 if (filename.length() > 250) { 570 return false; 571 } 572 nativeSynth.synthesizeToFile(text, filename); 573 } finally { 574 // This check is needed because finally will always run; even if the 575 // method returns somewhere in the try block. 576 if (synthAvailable) { 577 synthesizerLock.unlock(); 578 } 579 } 580 Log.i("TTS", "Completed synthesis for " + filename); 581 return true; 582 } 583 584 @Override 585 public IBinder onBind(Intent intent) { 586 if (ACTION.equals(intent.getAction())) { 587 for (String category : intent.getCategories()) { 588 if (category.equals(CATEGORY)) { 589 return mBinder; 590 } 591 } 592 } 593 return null; 594 } 595 596 private final android.speech.tts.ITts.Stub mBinder = new Stub() { 597 598 public void registerCallback(ITtsCallback cb) { 599 if (cb != null) 600 mCallbacks.register(cb); 601 } 602 603 public void unregisterCallback(ITtsCallback cb) { 604 if (cb != null) 605 mCallbacks.unregister(cb); 606 } 607 608 /** 609 * Speaks the given text using the specified queueing mode and 610 * parameters. 611 * 612 * @param text 613 * The text that should be spoken 614 * @param queueMode 615 * 0 for no queue (interrupts all previous utterances), 1 for 616 * queued 617 * @param params 618 * An ArrayList of parameters. The first element of this 619 * array controls the type of voice to use. 620 */ 621 public void speak(String text, int queueMode, String[] params) { 622 ArrayList<String> speakingParams = new ArrayList<String>(); 623 if (params != null) { 624 speakingParams = new ArrayList<String>(Arrays.asList(params)); 625 } 626 mSelf.speak(text, queueMode, speakingParams); 627 } 628 629 /** 630 * Plays the earcon using the specified queueing mode and parameters. 631 * 632 * @param earcon 633 * The earcon that should be played 634 * @param queueMode 635 * 0 for no queue (interrupts all previous utterances), 1 for 636 * queued 637 * @param params 638 * An ArrayList of parameters. 639 */ 640 public void playEarcon(String earcon, int queueMode, String[] params) { 641 ArrayList<String> speakingParams = new ArrayList<String>(); 642 if (params != null) { 643 speakingParams = new ArrayList<String>(Arrays.asList(params)); 644 } 645 mSelf.playEarcon(earcon, queueMode, speakingParams); 646 } 647 648 /** 649 * Plays the silence using the specified queueing mode and parameters. 650 * 651 * @param duration 652 * The duration of the silence that should be played 653 * @param queueMode 654 * 0 for no queue (interrupts all previous utterances), 1 for 655 * queued 656 * @param params 657 * An ArrayList of parameters. 658 */ 659 public void playSilence(long duration, int queueMode, String[] params) { 660 ArrayList<String> speakingParams = new ArrayList<String>(); 661 if (params != null) { 662 speakingParams = new ArrayList<String>(Arrays.asList(params)); 663 } 664 mSelf.playSilence(duration, queueMode, speakingParams); 665 } 666 667 /** 668 * Stops all speech output and removes any utterances still in the 669 * queue. 670 */ 671 public void stop() { 672 mSelf.stop(); 673 } 674 675 /** 676 * Returns whether or not the TTS is speaking. 677 * 678 * @return Boolean to indicate whether or not the TTS is speaking 679 */ 680 public boolean isSpeaking() { 681 return (mSelf.mIsSpeaking && (mSpeechQueue.size() < 1)); 682 } 683 684 /** 685 * Adds a sound resource to the TTS. 686 * 687 * @param text 688 * The text that should be associated with the sound resource 689 * @param packageName 690 * The name of the package which has the sound resource 691 * @param resId 692 * The resource ID of the sound within its package 693 */ 694 public void addSpeech(String text, String packageName, int resId) { 695 mSelf.addSpeech(text, packageName, resId); 696 } 697 698 /** 699 * Adds a sound resource to the TTS. 700 * 701 * @param text 702 * The text that should be associated with the sound resource 703 * @param filename 704 * The filename of the sound resource. This must be a 705 * complete path like: (/sdcard/mysounds/mysoundbite.mp3). 706 */ 707 public void addSpeechFile(String text, String filename) { 708 mSelf.addSpeech(text, filename); 709 } 710 711 /** 712 * Adds a sound resource to the TTS as an earcon. 713 * 714 * @param earcon 715 * The text that should be associated with the sound resource 716 * @param packageName 717 * The name of the package which has the sound resource 718 * @param resId 719 * The resource ID of the sound within its package 720 */ 721 public void addEarcon(String earcon, String packageName, int resId) { 722 mSelf.addEarcon(earcon, packageName, resId); 723 } 724 725 /** 726 * Adds a sound resource to the TTS as an earcon. 727 * 728 * @param earcon 729 * The text that should be associated with the sound resource 730 * @param filename 731 * The filename of the sound resource. This must be a 732 * complete path like: (/sdcard/mysounds/mysoundbite.mp3). 733 */ 734 public void addEarconFile(String earcon, String filename) { 735 mSelf.addEarcon(earcon, filename); 736 } 737 738 /** 739 * Sets the speech rate for the TTS. Note that this will only have an 740 * effect on synthesized speech; it will not affect pre-recorded speech. 741 * 742 * @param speechRate 743 * The speech rate that should be used 744 */ 745 public void setSpeechRate(int speechRate) { 746 mSelf.setSpeechRate(speechRate); 747 } 748 749 /** 750 * Sets the pitch for the TTS. Note that this will only have an 751 * effect on synthesized speech; it will not affect pre-recorded speech. 752 * 753 * @param pitch 754 * The pitch that should be used for the synthesized voice 755 */ 756 public void setPitch(int pitch) { 757 mSelf.setPitch(pitch); 758 } 759 760 /** 761 * Sets the speech rate for the TTS, which affects the synthesized voice. 762 * 763 * @param lang the three letter ISO language code. 764 * @param country the three letter ISO country code. 765 * @param variant the variant code associated with the country and language pair. 766 */ 767 public void setLanguage(String lang, String country, String variant) { 768 mSelf.setLanguage(lang, country, variant); 769 } 770 771 /** 772 * Speaks the given text using the specified queueing mode and 773 * parameters. 774 * 775 * @param text 776 * The String of text that should be synthesized 777 * @param params 778 * An ArrayList of parameters. The first element of this 779 * array controls the type of voice to use. 780 * @param filename 781 * The string that gives the full output filename; it should 782 * be something like "/sdcard/myappsounds/mysound.wav". 783 * @return A boolean that indicates if the synthesis succeeded 784 */ 785 public boolean synthesizeToFile(String text, String[] params, 786 String filename) { 787 ArrayList<String> speakingParams = new ArrayList<String>(); 788 if (params != null) { 789 speakingParams = new ArrayList<String>(Arrays.asList(params)); 790 } 791 return mSelf.synthesizeToFile(text, speakingParams, filename, true); 792 } 793 }; 794 795} 796