OSDN Git Service

c53f7ad9f8194393e99c01c6e08ce543b82c2de1
[radegast/radegast.git] / Radegast / Core / Media / MediaManager.cs
1 // 
2 // Radegast Metaverse Client
3 // Copyright (c) 2009-2013, Radegast Development Team
4 // All rights reserved.
5 // 
6 // Redistribution and use in source and binary forms, with or without
7 // modification, are permitted provided that the following conditions are met:
8 // 
9 //     * Redistributions of source code must retain the above copyright notice,
10 //       this list of conditions and the following disclaimer.
11 //     * Redistributions in binary form must reproduce the above copyright
12 //       notice, this list of conditions and the following disclaimer in the
13 //       documentation and/or other materials provided with the distribution.
14 //     * Neither the name of the application "Radegast", nor the names of its
15 //       contributors may be used to endorse or promote products derived from
16 //       this software without specific prior written permission.
17 // 
18 // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 //
29 // $Id$
30 //
31 using System;
32 using System.Collections.Generic;
33 using System.Text;
34 using FMOD;
35 using System.Threading;
36 using OpenMetaverse;
37 using OpenMetaverse.Assets;
38 #if (COGBOT_LIBOMV || USE_STHREADS)
39 using ThreadPoolUtil;
40 using Thread = ThreadPoolUtil.Thread;
41 using ThreadPool = ThreadPoolUtil.ThreadPool;
42 using Monitor = ThreadPoolUtil.Monitor;
43 #endif
44
45 namespace Radegast.Media
46 {
47     public class MediaManager : MediaObject
48     {
49         /// <summary>
50         /// Indicated wheather spund sytem is ready for use
51         /// </summary>
52         public bool SoundSystemAvailable { get { return soundSystemAvailable; } }
53         private bool soundSystemAvailable = false;
54         private Thread soundThread;
55         private Thread listenerThread;
56         public RadegastInstance Instance;
57
58         private List<MediaObject> sounds = new List<MediaObject>();
59         ManualResetEvent initDone = new ManualResetEvent(false);
60
61         public MediaManager(RadegastInstance instance)
62             : base()
63         {
64             this.Instance = instance;
65             manager = this;
66
67             if (MainProgram.CommandLine.DisableSound)
68             {
69                 soundSystemAvailable = false;
70                 return;
71             }
72
73             endCallback = new FMOD.CHANNEL_CALLBACK(DispatchEndCallback);
74             allBuffers = new Dictionary<UUID, BufferSound>();
75
76             // Start the background thread that does all the FMOD calls.
77             soundThread = new Thread(new ThreadStart(CommandLoop));
78             soundThread.IsBackground = true;
79             soundThread.Name = "SoundThread";
80             soundThread.Start();
81
82             // Start the background thread that updates listerner position.
83             listenerThread = new Thread(new ThreadStart(ListenerUpdate));
84             listenerThread.IsBackground = true;
85             listenerThread.Name = "ListenerThread";
86             listenerThread.Start();
87
88             Instance.ClientChanged += new EventHandler<ClientChangedEventArgs>(Instance_ClientChanged);
89
90             // Wait for init to complete
91             initDone.WaitOne();
92             initDone = null;
93         }
94
95         void Instance_ClientChanged(object sender, ClientChangedEventArgs e)
96         {
97             UnregisterClientEvents(e.OldClient);
98             if (ObjectEnable)
99                 RegisterClientEvents(e.Client);
100         }
101
102         void RegisterClientEvents(GridClient client)
103         {
104             client.Sound.SoundTrigger += new EventHandler<SoundTriggerEventArgs>(Sound_SoundTrigger);
105             client.Sound.AttachedSound += new EventHandler<AttachedSoundEventArgs>(Sound_AttachedSound);
106             client.Sound.PreloadSound += new EventHandler<PreloadSoundEventArgs>(Sound_PreloadSound);
107             client.Objects.ObjectUpdate += new EventHandler<PrimEventArgs>(Objects_ObjectUpdate);
108             client.Objects.KillObject += new EventHandler<KillObjectEventArgs>(Objects_KillObject);
109             client.Network.SimChanged += new EventHandler<SimChangedEventArgs>(Network_SimChanged);
110             client.Self.ChatFromSimulator += new EventHandler<ChatEventArgs>(Self_ChatFromSimulator);
111         }
112
113         void UnregisterClientEvents(GridClient client)
114         {
115             client.Sound.SoundTrigger -= new EventHandler<SoundTriggerEventArgs>(Sound_SoundTrigger);
116             client.Sound.AttachedSound -= new EventHandler<AttachedSoundEventArgs>(Sound_AttachedSound);
117             client.Sound.PreloadSound -= new EventHandler<PreloadSoundEventArgs>(Sound_PreloadSound);
118             client.Objects.ObjectUpdate -= new EventHandler<PrimEventArgs>(Objects_ObjectUpdate);
119             client.Objects.KillObject -= new EventHandler<KillObjectEventArgs>(Objects_KillObject);
120             client.Network.SimChanged -= new EventHandler<SimChangedEventArgs>(Network_SimChanged);
121             client.Self.ChatFromSimulator -= new EventHandler<ChatEventArgs>(Self_ChatFromSimulator);
122         }
123
124         /// <summary>
125         /// Thread that processes FMOD calls.
126         /// </summary>
127         private void CommandLoop()
128         {
129             SoundDelegate action = null;
130
131             // Initialze a bunch of static values
132             UpVector.x = 0.0f;
133             UpVector.y = 1.0f;
134             UpVector.z = 0.0f;
135             ZeroVector.x = 0.0f;
136             ZeroVector.y = 0.0f;
137             ZeroVector.z = 0.0f;
138
139             allSounds = new Dictionary<IntPtr, MediaObject>();
140             allChannels = new Dictionary<IntPtr, MediaObject>();
141
142             // Initialize the command queue.
143             queue = new Queue<SoundDelegate>();
144
145             // Initialize the FMOD sound package
146             InitFMOD();
147             initDone.Set();
148             if (!this.soundSystemAvailable) return;
149
150             while (true)
151             {
152                 // Wait for something to show up in the queue.
153                 lock (queue)
154                 {
155                     while (queue.Count == 0)
156                     {
157                         Monitor.Wait(queue);
158                     }
159                     action = queue.Dequeue();
160                 }
161
162                 // We have an action, so call it.
163                 try
164                 {
165                     action();
166                     action = null;
167                 }
168                 catch (Exception e)
169                 {
170                     Logger.Log("Error in sound action:\n    " + e.Message + "\n" + e.StackTrace,
171                         Helpers.LogLevel.Error);
172                 }
173             }
174         }
175
176         /// <summary>
177         /// Initialize the FMOD sound system.
178         /// </summary>
179         private void InitFMOD()
180         {
181             try
182             {
183                 FMODExec(FMOD.Factory.System_Create(ref system));
184                 uint version = 0;
185                 FMODExec(system.getVersion(ref version));
186
187                 if (version < FMOD.VERSION.number)
188                     throw new MediaException("You are using an old version of FMOD " +
189                         version.ToString("X") +
190                         ".  This program requires " +
191                         FMOD.VERSION.number.ToString("X") + ".");
192
193                 // Assume no special hardware capabilities except 5.1 surround sound.
194                 FMOD.CAPS caps = FMOD.CAPS.NONE;
195                 FMOD.SPEAKERMODE speakermode = FMOD.SPEAKERMODE._5POINT1;
196
197                 // Try to detect soud system used
198                 if (System.Environment.OSVersion.Platform == PlatformID.Unix || System.Environment.OSVersion.Platform == PlatformID.MacOSX)
199                 {
200                     bool audioOK = false;
201                     var res = system.setOutput(FMOD.OUTPUTTYPE.COREAUDIO);
202                     if (res == RESULT.OK)
203                     {
204                         audioOK = true;
205                     }
206
207                     if (!audioOK)
208                     {
209                         res = system.setOutput(FMOD.OUTPUTTYPE.PULSEAUDIO);
210                         if (res == RESULT.OK)
211                         {
212                             audioOK = true;
213                         }
214                     }
215
216                     if (!audioOK)
217                     {
218                         res = system.setOutput(FMOD.OUTPUTTYPE.ALSA);
219                         if (res == RESULT.OK)
220                         {
221                             audioOK = true;
222                         }
223                     }
224
225                     if (!audioOK)
226                     {
227                         res = system.setOutput(FMOD.OUTPUTTYPE.OSS);
228                         if (res == RESULT.OK)
229                         {
230                             audioOK = true;
231                         }
232                     }
233
234                     if (!audioOK)
235                     {
236                         res = system.setOutput(FMOD.OUTPUTTYPE.AUTODETECT);
237                         if (res == RESULT.OK)
238                         {
239                             audioOK = true;
240                         }
241                     }
242
243                 }
244
245                 FMOD.OUTPUTTYPE outputType = OUTPUTTYPE.UNKNOWN;
246                 FMODExec(system.getOutput(ref outputType));
247
248                 // Fancy param checking on Linux can cause init to fail
249                 try
250                 {
251                     // Get the capabilities of the driver.
252                     int outputRate = 0;
253                     FMODExec(system.getDriverCaps(0, ref caps,
254                         ref outputRate,
255                         ref speakermode));
256                     // Set FMOD speaker mode to what the driver supports.
257                    FMODExec(system.setSpeakerMode(speakermode));
258                 }
259                 catch {}
260
261                 // The user has the 'Acceleration' slider set to off, which
262                 // is really bad for latency.  At 48khz, the latency between
263                 // issuing an fmod command and hearing it will now be about 213ms.
264                 if ((caps & FMOD.CAPS.HARDWARE_EMULATED) == FMOD.CAPS.HARDWARE_EMULATED)
265                 {
266                     FMODExec(system.setDSPBufferSize(1024, 10));
267                 }
268
269                 try
270                 {
271                     StringBuilder name = new StringBuilder(128);   
272                     // Get driver information so we can check for a wierd one.
273                     FMOD.GUID guid = new FMOD.GUID();
274                     FMODExec(system.getDriverInfo(0, name, 128, ref guid));
275     
276                     // Sigmatel sound devices crackle for some reason if the format is pcm 16bit.
277                     // pcm floating point output seems to solve it.
278                     if (name.ToString().IndexOf("SigmaTel") != -1)
279                     {
280                         FMODExec(system.setSoftwareFormat(
281                             48000,
282                             FMOD.SOUND_FORMAT.PCMFLOAT,
283                             0, 0,
284                             FMOD.DSP_RESAMPLER.LINEAR)
285                         );
286                     }
287                 }
288                 catch {}
289                 
290                 // Try to initialize with all those settings, and Max 32 channels.
291                 FMOD.RESULT result = system.init(32, FMOD.INITFLAGS.NORMAL, (IntPtr)null);
292                 if (result == FMOD.RESULT.ERR_OUTPUT_CREATEBUFFER)
293                 {
294                     // Can not handle surround sound - back to Stereo.
295                     FMODExec(system.setSpeakerMode(FMOD.SPEAKERMODE.STEREO));
296
297                     // And init again.
298                     FMODExec(system.init(
299                         32,
300                         FMOD.INITFLAGS.NORMAL,
301                         (IntPtr)null)
302                     );
303                 }
304                 else if (result != FMOD.RESULT.OK)
305                 {
306                     throw(new Exception(result.ToString()));
307                 }
308
309                 // Set real-world effect scales.
310                 FMODExec(system.set3DSettings(
311                     1.0f,   // Doppler scale
312                     1.0f,   // Distance scale is meters
313                     1.0f)   // Rolloff factor
314                 );
315
316                 soundSystemAvailable = true;
317                 Logger.Log("Initialized FMOD Ex: " + outputType.ToString(), Helpers.LogLevel.Debug);
318             }
319             catch (Exception ex)
320             {
321                 Logger.Log("Failed to initialize the sound system: " + ex.ToString(), Helpers.LogLevel.Warning);
322             }
323         }
324
325         public override void Dispose()
326         {
327             if (Instance.Client != null)
328                 UnregisterClientEvents(Instance.Client);
329
330             lock (sounds)
331             {
332                 for (int i = 0; i < sounds.Count; i++)
333                 {
334                     if (!sounds[i].Disposed)
335                         sounds[i].Dispose();
336                 }
337                 sounds.Clear();
338             }
339
340             sounds = null;
341
342             if (system != null)
343             {
344                 Logger.Log("FMOD interface stopping", Helpers.LogLevel.Info);
345                 system.release();
346                 system = null;
347             }
348
349             if (listenerThread != null)
350             {
351                 if (listenerThread.IsAlive)
352                     listenerThread.Abort();
353                 listenerThread = null;
354             }
355
356             if (soundThread != null)
357             {
358                 if (soundThread.IsAlive)
359                     soundThread.Abort();
360                 soundThread = null;
361             }
362
363             base.Dispose();
364         }
365
366         /// <summary>
367         /// Thread to update listener position and generally keep
368         /// FMOD up to date.
369         /// </summary>
370         private void ListenerUpdate()
371         {
372             // Notice changes in position or direction.
373             Vector3 lastpos = new Vector3(0.0f, 0.0f, 0.0f);
374             float lastface = 0.0f;
375
376             while (true)
377             {
378                 // Two updates per second.
379                 Thread.Sleep(500);
380
381                 if (system == null) continue;
382
383                 AgentManager my = Instance.Client.Self;
384                 Vector3 newPosition = new Vector3(my.SimPosition);
385                 float newFace = my.SimRotation.W;
386
387                 // If we are standing still, nothing to update now, but
388                 // FMOD needs a 'tick' anyway for callbacks, etc.  In looping
389                 // 'game' programs, the loop is the 'tick'.   Since Radegast
390                 // uses events and has no loop, we use this position update
391                 // thread to drive the FMOD tick.  Have to move more than
392                 // 500mm or turn more than 10 desgrees to bother with.
393                 //
394                 if (newPosition.ApproxEquals(lastpos, 0.5f) &&
395                     Math.Abs(newFace - lastface) < 0.2)
396                 {
397                     invoke(new SoundDelegate(delegate
398                     {
399                         FMODExec(system.update());
400                     }));
401                     continue;
402                 }
403
404                 // We have moved or turned.  Remember new position.
405                 lastpos = newPosition;
406                 lastface = newFace;
407
408                 // Convert coordinate spaces.
409                 FMOD.VECTOR listenerpos = FromOMVSpace(newPosition);
410
411                 // Get azimuth from the facing Quaternion.  Note we assume the
412                 // avatar is standing upright.  Avatars in unusual positions
413                 // hear things from unpredictable directions.
414                 // By definition, facing.W = Cos( angle/2 )
415                 // With angle=0 meaning East.
416                 double angle = 2.0 * Math.Acos(newFace);
417
418                 // Construct facing unit vector in FMOD coordinates.
419                 // Z is East, X is South, Y is up.
420                 FMOD.VECTOR forward = new FMOD.VECTOR();
421                 forward.x = (float)Math.Sin(angle); // South
422                 forward.y = 0.0f;
423                 forward.z = (float)Math.Cos(angle); // East
424
425                 //Logger.Log(
426                 //    String.Format(
427                 //        "Standing at <{0:0.0},{1:0.0},{2:0.0}> facing {3:d}",
428                 //            listenerpos.x,
429                 //            listenerpos.y,
430                 //            listenerpos.z,
431                 //            (int)(angle * 180.0 / 3.141592)),
432                 //    Helpers.LogLevel.Debug);
433
434                 // Tell FMOD the new orientation.
435                 invoke(new SoundDelegate(delegate
436                 {
437                     FMODExec(system.set3DListenerAttributes(
438                         0,
439                         ref listenerpos,    // Position
440                         ref ZeroVector,        // Velocity
441                         ref forward,        // Facing direction
442                         ref UpVector));    // Top of head
443
444                     FMODExec(system.update());
445                 }));
446             }
447         }
448
449         /// <summary>
450         /// Handle request to play a sound, which might (or mioght not) have been preloaded.
451         /// </summary>
452         /// <param name="sender"></param>
453         /// <param name="e"></param>
454         private void Sound_SoundTrigger(object sender, SoundTriggerEventArgs e)
455         {
456             if (e.SoundID == UUID.Zero) return;
457
458             Logger.Log("Trigger sound " + e.SoundID.ToString() +
459                 " in object " + e.ObjectID.ToString(),
460                 Helpers.LogLevel.Debug);
461
462             new BufferSound(
463                 e.ObjectID,
464                 e.SoundID,
465                 false,
466                 true,
467                 e.Position,
468                 e.Gain * ObjectVolume);
469         }
470
471         /// <summary>
472         /// Handle sound attached to an object
473         /// </summary>
474         /// <param name="sender"></param>
475         /// <param name="e"></param>
476         private void Sound_AttachedSound(object sender, AttachedSoundEventArgs e)
477         {
478             // This event tells us the Object ID, but not the Prim info directly.
479             // So we look it up in our internal Object memory.
480             Simulator sim = e.Simulator;
481             Primitive p = sim.ObjectsPrimitives.Find((Primitive p2) => { return p2.ID == e.ObjectID; });
482             if (p == null) return;
483
484             // Only one attached sound per prim, so we kill any previous
485             BufferSound.Kill(p.ID);
486
487             // If this is stop sound, we're done since we've already killed sound for this object
488             if ((e.Flags & SoundFlags.Stop) == SoundFlags.Stop)
489             {
490                 return;
491             }
492
493             // We seem to get a lot of these zero sounds.
494             if (e.SoundID == UUID.Zero) return;
495
496             // If this is a child prim, its position is relative to the root.
497             Vector3 fullPosition = p.Position;
498
499             while (p != null && p.ParentID != 0)
500             {
501                 Avatar av;
502                 if (sim.ObjectsAvatars.TryGetValue(p.ParentID, out av))
503                 {
504                     p = av;
505                     fullPosition += p.Position;
506                 }
507                 else
508                 {
509                     if (sim.ObjectsPrimitives.TryGetValue(p.ParentID, out p))
510                     {
511                         fullPosition += p.Position;
512                     }
513                 }
514             }
515
516             // Didn't find root prim
517             if (p == null) return;
518
519             new BufferSound(
520                 e.ObjectID,
521                 e.SoundID,
522                 (e.Flags & SoundFlags.Loop) == SoundFlags.Loop,
523                 true,
524                 fullPosition,
525                 e.Gain * ObjectVolume);
526         }
527
528
529         /// <summary>
530         /// Handle request to preload a sound for playing later.
531         /// </summary>
532         /// <param name="sender"></param>
533         /// <param name="e"></param>
534         private void Sound_PreloadSound(object sender, PreloadSoundEventArgs e)
535         {
536             if (e.SoundID == UUID.Zero) return;
537
538             if (!Instance.Client.Assets.Cache.HasAsset(e.SoundID))
539                 new BufferSound(e.SoundID);
540         }
541
542         /// <summary>
543         /// Handle object updates, looking for sound events
544         /// </summary>
545         /// <param name="sender"></param>
546         /// <param name="e"></param>
547         private void Objects_ObjectUpdate(object sender, PrimEventArgs e)
548         {
549             HandleObjectSound(e.Prim, e.Simulator);
550         }
551
552         /// <summary>
553         /// Handle deletion of a noise-making object
554         /// </summary>
555         /// <param name="sender"></param>
556         /// <param name="e"></param>
557         void Objects_KillObject(object sender, KillObjectEventArgs e)
558         {
559             Primitive p = null;
560             if (!e.Simulator.ObjectsPrimitives.TryGetValue(e.ObjectLocalID, out  p)) return;
561
562             // Objects without sounds are not interesting.
563             if (p.Sound == UUID.Zero) return;
564
565             BufferSound.Kill(p.ID);
566         }
567
568         /// <summary>
569         /// Common object sound processing for various Update events
570         /// </summary>
571         /// <param name="p"></param>
572         /// <param name="s"></param>
573         private void HandleObjectSound(Primitive p, Simulator s)
574         {
575             // Objects without sounds are not interesting.
576             if (p.Sound == UUID.Zero) return;
577
578             if ((p.SoundFlags & SoundFlags.Stop) == SoundFlags.Stop)
579             {
580                 BufferSound.Kill(p.ID);
581                 return;
582             }
583
584             // If this is a child prim, its position is relative to the root prim.
585             Vector3 fullPosition = p.Position;
586             if (p.ParentID != 0)
587             {
588                 Primitive parentP;
589                 if (!s.ObjectsPrimitives.TryGetValue(p.ParentID, out parentP)) return;
590                 fullPosition += parentP.Position;
591             }
592
593             // See if this is an update to  something we already know about.
594             if (allBuffers.ContainsKey(p.ID))
595             {
596                 // Exists already, so modify existing sound.
597                 BufferSound snd = allBuffers[p.ID];
598                 snd.Volume = p.SoundGain * ObjectVolume;
599                 snd.Position = fullPosition;
600             }
601             else
602             {
603                 // Does not exist, so create a new one.
604                 new BufferSound(
605                     p.ID,
606                     p.Sound,
607                     (p.SoundFlags & SoundFlags.Loop) == SoundFlags.Loop,
608                     true,
609                     fullPosition, //Instance.State.GlobalPosition(e.Simulator, fullPosition),
610                     p.SoundGain * ObjectVolume);
611             }
612         }
613
614         /// <summary>
615         /// Control the volume of all inworld sounds
616         /// </summary>
617         public float ObjectVolume
618         {
619             set
620             {
621                 AllObjectVolume = value;
622                 BufferSound.AdjustVolumes();
623             }
624             get { return AllObjectVolume; }
625         }
626
627         /// <summary>
628         /// UI sounds volume
629         /// </summary>
630         public float UIVolume = 0.5f;
631
632         private bool m_objectEnabled = true;
633         /// <summary>
634         /// Enable and Disable inworld sounds
635         /// </summary>
636         public bool ObjectEnable
637         {
638             set
639             {
640                 if (value)
641                 {
642                     // Subscribe to events about inworld sounds
643                     RegisterClientEvents(Instance.Client);
644                     Logger.Log("Inworld sound enabled", Helpers.LogLevel.Info);
645                 }
646                 else
647                 {
648                     // Subscribe to events about inworld sounds
649                     UnregisterClientEvents(Instance.Client);
650                     // Stop all running sounds
651                     BufferSound.KillAll();
652                     Logger.Log("Inworld sound disabled", Helpers.LogLevel.Info);
653                 }
654
655                 m_objectEnabled = value;
656             }
657             get { return m_objectEnabled; }
658         }
659
660         void Self_ChatFromSimulator(object sender, ChatEventArgs e)
661         {
662             if (e.Type == ChatType.StartTyping)
663             {
664                 new BufferSound(
665                     UUID.Random(),
666                     UISounds.Typing,
667                     false,
668                     true,
669                     e.Position,
670                     ObjectVolume / 2f);
671             }
672         }
673
674         /// <summary>
675         /// Watch for Teleports to cancel all the old sounds
676         /// </summary>
677         /// <param name="sender"></param>
678         /// <param name="e"></param>
679         void Network_SimChanged(object sender, SimChangedEventArgs e)
680         {
681             BufferSound.KillAll();
682         }
683
684         /// <summary>
685         /// Plays a sound
686         /// </summary>
687         /// <param name="sound">UUID of the sound to play</param>
688         public void PlayUISound(UUID sound)
689         {
690             if (!soundSystemAvailable) return;
691
692             new BufferSound(
693                 UUID.Random(),
694                 sound,
695                 false,
696                 true,
697                 Instance.Client.Self.SimPosition,
698                 UIVolume);
699         }
700
701
702     }
703
704     public class MediaException : Exception
705     {
706         public MediaException(string msg)
707             : base(msg)
708         {
709         }
710     }
711 }