|
1 /* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ |
|
2 /* vim:set ts=2 sw=2 sts=2 et cindent: */ |
|
3 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
4 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
6 |
|
7 #ifndef AudioEventTimeline_h_ |
|
8 #define AudioEventTimeline_h_ |
|
9 |
|
10 #include <algorithm> |
|
11 #include "mozilla/Assertions.h" |
|
12 #include "mozilla/FloatingPoint.h" |
|
13 #include "mozilla/TypedEnum.h" |
|
14 #include "mozilla/PodOperations.h" |
|
15 |
|
16 #include "nsTArray.h" |
|
17 #include "math.h" |
|
18 |
|
19 namespace mozilla { |
|
20 |
|
21 namespace dom { |
|
22 |
|
23 // This is an internal helper class and should not be used outside of this header. |
|
24 struct AudioTimelineEvent { |
|
25 enum Type MOZ_ENUM_TYPE(uint32_t) { |
|
26 SetValue, |
|
27 LinearRamp, |
|
28 ExponentialRamp, |
|
29 SetTarget, |
|
30 SetValueCurve |
|
31 }; |
|
32 |
|
33 AudioTimelineEvent(Type aType, double aTime, float aValue, double aTimeConstant = 0.0, |
|
34 float aDuration = 0.0, const float* aCurve = nullptr, uint32_t aCurveLength = 0) |
|
35 : mType(aType) |
|
36 , mTimeConstant(aTimeConstant) |
|
37 , mDuration(aDuration) |
|
38 #ifdef DEBUG |
|
39 , mTimeIsInTicks(false) |
|
40 #endif |
|
41 { |
|
42 mTime = aTime; |
|
43 if (aType == AudioTimelineEvent::SetValueCurve) { |
|
44 SetCurveParams(aCurve, aCurveLength); |
|
45 } else { |
|
46 mValue = aValue; |
|
47 } |
|
48 } |
|
49 |
|
50 AudioTimelineEvent(const AudioTimelineEvent& rhs) |
|
51 { |
|
52 PodCopy(this, &rhs, 1); |
|
53 if (rhs.mType == AudioTimelineEvent::SetValueCurve) { |
|
54 SetCurveParams(rhs.mCurve, rhs.mCurveLength); |
|
55 } |
|
56 } |
|
57 |
|
58 ~AudioTimelineEvent() |
|
59 { |
|
60 if (mType == AudioTimelineEvent::SetValueCurve) { |
|
61 delete[] mCurve; |
|
62 } |
|
63 } |
|
64 |
|
65 bool IsValid() const |
|
66 { |
|
67 if (mType == AudioTimelineEvent::SetValueCurve) { |
|
68 if (!mCurve || !mCurveLength) { |
|
69 return false; |
|
70 } |
|
71 for (uint32_t i = 0; i < mCurveLength; ++i) { |
|
72 if (!IsValid(mCurve[i])) { |
|
73 return false; |
|
74 } |
|
75 } |
|
76 } |
|
77 |
|
78 return IsValid(mTime) && |
|
79 IsValid(mValue) && |
|
80 IsValid(mTimeConstant) && |
|
81 IsValid(mDuration); |
|
82 } |
|
83 |
|
84 template <class TimeType> |
|
85 TimeType Time() const; |
|
86 |
|
87 void SetTimeInTicks(int64_t aTimeInTicks) |
|
88 { |
|
89 mTimeInTicks = aTimeInTicks; |
|
90 #ifdef DEBUG |
|
91 mTimeIsInTicks = true; |
|
92 #endif |
|
93 } |
|
94 |
|
95 void SetCurveParams(const float* aCurve, uint32_t aCurveLength) { |
|
96 mCurveLength = aCurveLength; |
|
97 if (aCurveLength) { |
|
98 mCurve = new float[aCurveLength]; |
|
99 PodCopy(mCurve, aCurve, aCurveLength); |
|
100 } else { |
|
101 mCurve = nullptr; |
|
102 } |
|
103 } |
|
104 |
|
105 Type mType; |
|
106 union { |
|
107 float mValue; |
|
108 uint32_t mCurveLength; |
|
109 }; |
|
110 // The time for an event can either be in absolute value or in ticks. |
|
111 // Initially the time of the event is always in absolute value. |
|
112 // In order to convert it to ticks, call SetTimeInTicks. Once this |
|
113 // method has been called for an event, the time cannot be converted |
|
114 // back to absolute value. |
|
115 union { |
|
116 double mTime; |
|
117 int64_t mTimeInTicks; |
|
118 }; |
|
119 // mCurve contains a buffer of SetValueCurve samples. We sample the |
|
120 // values in the buffer depending on how far along we are in time. |
|
121 // If we're at time T and the event has started as time T0 and has a |
|
122 // duration of D, we sample the buffer at floor(mCurveLength*(T-T0)/D) |
|
123 // if T<T0+D, and just take the last sample in the buffer otherwise. |
|
124 float* mCurve; |
|
125 double mTimeConstant; |
|
126 double mDuration; |
|
127 #ifdef DEBUG |
|
128 bool mTimeIsInTicks; |
|
129 #endif |
|
130 |
|
131 private: |
|
132 static bool IsValid(double value) |
|
133 { |
|
134 return mozilla::IsFinite(value); |
|
135 } |
|
136 }; |
|
137 |
|
138 template <> |
|
139 inline double AudioTimelineEvent::Time<double>() const |
|
140 { |
|
141 MOZ_ASSERT(!mTimeIsInTicks); |
|
142 return mTime; |
|
143 } |
|
144 |
|
145 template <> |
|
146 inline int64_t AudioTimelineEvent::Time<int64_t>() const |
|
147 { |
|
148 MOZ_ASSERT(mTimeIsInTicks); |
|
149 return mTimeInTicks; |
|
150 } |
|
151 |
|
152 /** |
|
153 * This class will be instantiated with different template arguments for testing and |
|
154 * production code. |
|
155 * |
|
156 * ErrorResult is a type which satisfies the following: |
|
157 * - Implements a Throw() method taking an nsresult argument, representing an error code. |
|
158 */ |
|
159 template <class ErrorResult> |
|
160 class AudioEventTimeline |
|
161 { |
|
162 public: |
|
163 explicit AudioEventTimeline(float aDefaultValue) |
|
164 : mValue(aDefaultValue), |
|
165 mComputedValue(aDefaultValue), |
|
166 mLastComputedValue(aDefaultValue) |
|
167 { |
|
168 } |
|
169 |
|
170 bool HasSimpleValue() const |
|
171 { |
|
172 return mEvents.IsEmpty(); |
|
173 } |
|
174 |
|
175 float GetValue() const |
|
176 { |
|
177 // This method should only be called if HasSimpleValue() returns true |
|
178 MOZ_ASSERT(HasSimpleValue()); |
|
179 return mValue; |
|
180 } |
|
181 |
|
182 float Value() const |
|
183 { |
|
184 // TODO: Return the current value based on the timeline of the AudioContext |
|
185 return mValue; |
|
186 } |
|
187 |
|
188 void SetValue(float aValue) |
|
189 { |
|
190 // Silently don't change anything if there are any events |
|
191 if (mEvents.IsEmpty()) { |
|
192 mLastComputedValue = mComputedValue = mValue = aValue; |
|
193 } |
|
194 } |
|
195 |
|
196 void SetValueAtTime(float aValue, double aStartTime, ErrorResult& aRv) |
|
197 { |
|
198 InsertEvent(AudioTimelineEvent(AudioTimelineEvent::SetValue, aStartTime, aValue), aRv); |
|
199 } |
|
200 |
|
201 void LinearRampToValueAtTime(float aValue, double aEndTime, ErrorResult& aRv) |
|
202 { |
|
203 InsertEvent(AudioTimelineEvent(AudioTimelineEvent::LinearRamp, aEndTime, aValue), aRv); |
|
204 } |
|
205 |
|
206 void ExponentialRampToValueAtTime(float aValue, double aEndTime, ErrorResult& aRv) |
|
207 { |
|
208 InsertEvent(AudioTimelineEvent(AudioTimelineEvent::ExponentialRamp, aEndTime, aValue), aRv); |
|
209 } |
|
210 |
|
211 void SetTargetAtTime(float aTarget, double aStartTime, double aTimeConstant, ErrorResult& aRv) |
|
212 { |
|
213 InsertEvent(AudioTimelineEvent(AudioTimelineEvent::SetTarget, aStartTime, aTarget, aTimeConstant), aRv); |
|
214 } |
|
215 |
|
216 void SetValueCurveAtTime(const float* aValues, uint32_t aValuesLength, double aStartTime, double aDuration, ErrorResult& aRv) |
|
217 { |
|
218 InsertEvent(AudioTimelineEvent(AudioTimelineEvent::SetValueCurve, aStartTime, 0.0f, 0.0f, aDuration, aValues, aValuesLength), aRv); |
|
219 } |
|
220 |
|
221 void CancelScheduledValues(double aStartTime) |
|
222 { |
|
223 for (unsigned i = 0; i < mEvents.Length(); ++i) { |
|
224 if (mEvents[i].mTime >= aStartTime) { |
|
225 #ifdef DEBUG |
|
226 // Sanity check: the array should be sorted, so all of the following |
|
227 // events should have a time greater than aStartTime too. |
|
228 for (unsigned j = i + 1; j < mEvents.Length(); ++j) { |
|
229 MOZ_ASSERT(mEvents[j].mTime >= aStartTime); |
|
230 } |
|
231 #endif |
|
232 mEvents.TruncateLength(i); |
|
233 break; |
|
234 } |
|
235 } |
|
236 } |
|
237 |
|
238 void CancelAllEvents() |
|
239 { |
|
240 mEvents.Clear(); |
|
241 } |
|
242 |
|
243 static bool TimesEqual(int64_t aLhs, int64_t aRhs) |
|
244 { |
|
245 return aLhs == aRhs; |
|
246 } |
|
247 |
|
248 // Since we are going to accumulate error by adding 0.01 multiple time in a |
|
249 // loop, we want to fuzz the equality check in GetValueAtTime. |
|
250 static bool TimesEqual(double aLhs, double aRhs) |
|
251 { |
|
252 const float kEpsilon = 0.0000000001f; |
|
253 return fabs(aLhs - aRhs) < kEpsilon; |
|
254 } |
|
255 |
|
256 template<class TimeType> |
|
257 float GetValueAtTime(TimeType aTime) |
|
258 { |
|
259 mComputedValue = GetValueAtTimeHelper(aTime); |
|
260 return mComputedValue; |
|
261 } |
|
262 |
|
263 // This method computes the AudioParam value at a given time based on the event timeline |
|
264 template<class TimeType> |
|
265 float GetValueAtTimeHelper(TimeType aTime) |
|
266 { |
|
267 const AudioTimelineEvent* previous = nullptr; |
|
268 const AudioTimelineEvent* next = nullptr; |
|
269 |
|
270 bool bailOut = false; |
|
271 for (unsigned i = 0; !bailOut && i < mEvents.Length(); ++i) { |
|
272 switch (mEvents[i].mType) { |
|
273 case AudioTimelineEvent::SetValue: |
|
274 case AudioTimelineEvent::SetTarget: |
|
275 case AudioTimelineEvent::LinearRamp: |
|
276 case AudioTimelineEvent::ExponentialRamp: |
|
277 case AudioTimelineEvent::SetValueCurve: |
|
278 if (TimesEqual(aTime, mEvents[i].template Time<TimeType>())) { |
|
279 mLastComputedValue = mComputedValue; |
|
280 // Find the last event with the same time |
|
281 do { |
|
282 ++i; |
|
283 } while (i < mEvents.Length() && |
|
284 aTime == mEvents[i].template Time<TimeType>()); |
|
285 |
|
286 // SetTarget nodes can be handled no matter what their next node is (if they have one) |
|
287 if (mEvents[i - 1].mType == AudioTimelineEvent::SetTarget) { |
|
288 // Follow the curve, without regard to the next event, starting at |
|
289 // the last value of the last event. |
|
290 return ExponentialApproach(mEvents[i - 1].template Time<TimeType>(), |
|
291 mLastComputedValue, mEvents[i - 1].mValue, |
|
292 mEvents[i - 1].mTimeConstant, aTime); |
|
293 } |
|
294 |
|
295 // SetValueCurve events can be handled no matter what their event node is (if they have one) |
|
296 if (mEvents[i - 1].mType == AudioTimelineEvent::SetValueCurve) { |
|
297 return ExtractValueFromCurve(mEvents[i - 1].template Time<TimeType>(), |
|
298 mEvents[i - 1].mCurve, |
|
299 mEvents[i - 1].mCurveLength, |
|
300 mEvents[i - 1].mDuration, aTime); |
|
301 } |
|
302 |
|
303 // For other event types |
|
304 return mEvents[i - 1].mValue; |
|
305 } |
|
306 previous = next; |
|
307 next = &mEvents[i]; |
|
308 if (aTime < mEvents[i].template Time<TimeType>()) { |
|
309 bailOut = true; |
|
310 } |
|
311 break; |
|
312 default: |
|
313 MOZ_ASSERT(false, "unreached"); |
|
314 } |
|
315 } |
|
316 // Handle the case where the time is past all of the events |
|
317 if (!bailOut) { |
|
318 previous = next; |
|
319 next = nullptr; |
|
320 } |
|
321 |
|
322 // Just return the default value if we did not find anything |
|
323 if (!previous && !next) { |
|
324 return mValue; |
|
325 } |
|
326 |
|
327 // If the requested time is before all of the existing events |
|
328 if (!previous) { |
|
329 return mValue; |
|
330 } |
|
331 |
|
332 // SetTarget nodes can be handled no matter what their next node is (if they have one) |
|
333 if (previous->mType == AudioTimelineEvent::SetTarget) { |
|
334 return ExponentialApproach(previous->template Time<TimeType>(), |
|
335 mLastComputedValue, previous->mValue, |
|
336 previous->mTimeConstant, aTime); |
|
337 } |
|
338 |
|
339 // SetValueCurve events can be handled no mattar what their next node is (if they have one) |
|
340 if (previous->mType == AudioTimelineEvent::SetValueCurve) { |
|
341 return ExtractValueFromCurve(previous->template Time<TimeType>(), |
|
342 previous->mCurve, previous->mCurveLength, |
|
343 previous->mDuration, aTime); |
|
344 } |
|
345 |
|
346 // If the requested time is after all of the existing events |
|
347 if (!next) { |
|
348 switch (previous->mType) { |
|
349 case AudioTimelineEvent::SetValue: |
|
350 case AudioTimelineEvent::LinearRamp: |
|
351 case AudioTimelineEvent::ExponentialRamp: |
|
352 // The value will be constant after the last event |
|
353 return previous->mValue; |
|
354 case AudioTimelineEvent::SetValueCurve: |
|
355 return ExtractValueFromCurve(previous->template Time<TimeType>(), |
|
356 previous->mCurve, previous->mCurveLength, |
|
357 previous->mDuration, aTime); |
|
358 case AudioTimelineEvent::SetTarget: |
|
359 MOZ_ASSERT(false, "unreached"); |
|
360 } |
|
361 MOZ_ASSERT(false, "unreached"); |
|
362 } |
|
363 |
|
364 // Finally, handle the case where we have both a previous and a next event |
|
365 |
|
366 // First, handle the case where our range ends up in a ramp event |
|
367 switch (next->mType) { |
|
368 case AudioTimelineEvent::LinearRamp: |
|
369 return LinearInterpolate(previous->template Time<TimeType>(), previous->mValue, next->template Time<TimeType>(), next->mValue, aTime); |
|
370 case AudioTimelineEvent::ExponentialRamp: |
|
371 return ExponentialInterpolate(previous->template Time<TimeType>(), previous->mValue, next->template Time<TimeType>(), next->mValue, aTime); |
|
372 case AudioTimelineEvent::SetValue: |
|
373 case AudioTimelineEvent::SetTarget: |
|
374 case AudioTimelineEvent::SetValueCurve: |
|
375 break; |
|
376 } |
|
377 |
|
378 // Now handle all other cases |
|
379 switch (previous->mType) { |
|
380 case AudioTimelineEvent::SetValue: |
|
381 case AudioTimelineEvent::LinearRamp: |
|
382 case AudioTimelineEvent::ExponentialRamp: |
|
383 // If the next event type is neither linear or exponential ramp, the |
|
384 // value is constant. |
|
385 return previous->mValue; |
|
386 case AudioTimelineEvent::SetValueCurve: |
|
387 return ExtractValueFromCurve(previous->template Time<TimeType>(), |
|
388 previous->mCurve, previous->mCurveLength, |
|
389 previous->mDuration, aTime); |
|
390 case AudioTimelineEvent::SetTarget: |
|
391 MOZ_ASSERT(false, "unreached"); |
|
392 } |
|
393 |
|
394 MOZ_ASSERT(false, "unreached"); |
|
395 return 0.0f; |
|
396 } |
|
397 |
|
398 // Return the number of events scheduled |
|
399 uint32_t GetEventCount() const |
|
400 { |
|
401 return mEvents.Length(); |
|
402 } |
|
403 |
|
404 static float LinearInterpolate(double t0, float v0, double t1, float v1, double t) |
|
405 { |
|
406 return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); |
|
407 } |
|
408 |
|
409 static float ExponentialInterpolate(double t0, float v0, double t1, float v1, double t) |
|
410 { |
|
411 return v0 * powf(v1 / v0, (t - t0) / (t1 - t0)); |
|
412 } |
|
413 |
|
414 static float ExponentialApproach(double t0, double v0, float v1, double timeConstant, double t) |
|
415 { |
|
416 return v1 + (v0 - v1) * expf(-(t - t0) / timeConstant); |
|
417 } |
|
418 |
|
419 static float ExtractValueFromCurve(double startTime, float* aCurve, uint32_t aCurveLength, double duration, double t) |
|
420 { |
|
421 if (t >= startTime + duration) { |
|
422 // After the duration, return the last curve value |
|
423 return aCurve[aCurveLength - 1]; |
|
424 } |
|
425 double ratio = std::max((t - startTime) / duration, 0.0); |
|
426 if (ratio >= 1.0) { |
|
427 return aCurve[aCurveLength - 1]; |
|
428 } |
|
429 return aCurve[uint32_t(aCurveLength * ratio)]; |
|
430 } |
|
431 |
|
432 void ConvertEventTimesToTicks(int64_t (*aConvertor)(double aTime, void* aClosure), void* aClosure, |
|
433 int32_t aSampleRate) |
|
434 { |
|
435 for (unsigned i = 0; i < mEvents.Length(); ++i) { |
|
436 mEvents[i].SetTimeInTicks(aConvertor(mEvents[i].template Time<double>(), aClosure)); |
|
437 mEvents[i].mTimeConstant *= aSampleRate; |
|
438 mEvents[i].mDuration *= aSampleRate; |
|
439 } |
|
440 } |
|
441 |
|
442 private: |
|
443 const AudioTimelineEvent* GetPreviousEvent(double aTime) const |
|
444 { |
|
445 const AudioTimelineEvent* previous = nullptr; |
|
446 const AudioTimelineEvent* next = nullptr; |
|
447 |
|
448 bool bailOut = false; |
|
449 for (unsigned i = 0; !bailOut && i < mEvents.Length(); ++i) { |
|
450 switch (mEvents[i].mType) { |
|
451 case AudioTimelineEvent::SetValue: |
|
452 case AudioTimelineEvent::SetTarget: |
|
453 case AudioTimelineEvent::LinearRamp: |
|
454 case AudioTimelineEvent::ExponentialRamp: |
|
455 case AudioTimelineEvent::SetValueCurve: |
|
456 if (aTime == mEvents[i].mTime) { |
|
457 // Find the last event with the same time |
|
458 do { |
|
459 ++i; |
|
460 } while (i < mEvents.Length() && |
|
461 aTime == mEvents[i].mTime); |
|
462 return &mEvents[i - 1]; |
|
463 } |
|
464 previous = next; |
|
465 next = &mEvents[i]; |
|
466 if (aTime < mEvents[i].mTime) { |
|
467 bailOut = true; |
|
468 } |
|
469 break; |
|
470 default: |
|
471 MOZ_ASSERT(false, "unreached"); |
|
472 } |
|
473 } |
|
474 // Handle the case where the time is past all of the events |
|
475 if (!bailOut) { |
|
476 previous = next; |
|
477 } |
|
478 |
|
479 return previous; |
|
480 } |
|
481 |
|
482 void InsertEvent(const AudioTimelineEvent& aEvent, ErrorResult& aRv) |
|
483 { |
|
484 if (!aEvent.IsValid()) { |
|
485 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
486 return; |
|
487 } |
|
488 |
|
489 // Make sure that non-curve events don't fall within the duration of a |
|
490 // curve event. |
|
491 for (unsigned i = 0; i < mEvents.Length(); ++i) { |
|
492 if (mEvents[i].mType == AudioTimelineEvent::SetValueCurve && |
|
493 mEvents[i].mTime <= aEvent.mTime && |
|
494 (mEvents[i].mTime + mEvents[i].mDuration) >= aEvent.mTime) { |
|
495 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
496 return; |
|
497 } |
|
498 } |
|
499 |
|
500 // Make sure that curve events don't fall in a range which includes other |
|
501 // events. |
|
502 if (aEvent.mType == AudioTimelineEvent::SetValueCurve) { |
|
503 for (unsigned i = 0; i < mEvents.Length(); ++i) { |
|
504 if (mEvents[i].mTime > aEvent.mTime && |
|
505 mEvents[i].mTime < (aEvent.mTime + aEvent.mDuration)) { |
|
506 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
507 return; |
|
508 } |
|
509 } |
|
510 } |
|
511 |
|
512 // Make sure that invalid values are not used for exponential curves |
|
513 if (aEvent.mType == AudioTimelineEvent::ExponentialRamp) { |
|
514 if (aEvent.mValue <= 0.f) { |
|
515 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
516 return; |
|
517 } |
|
518 const AudioTimelineEvent* previousEvent = GetPreviousEvent(aEvent.mTime); |
|
519 if (previousEvent) { |
|
520 if (previousEvent->mValue <= 0.f) { |
|
521 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
522 return; |
|
523 } |
|
524 } else { |
|
525 if (mValue <= 0.f) { |
|
526 aRv.Throw(NS_ERROR_DOM_SYNTAX_ERR); |
|
527 return; |
|
528 } |
|
529 } |
|
530 } |
|
531 |
|
532 for (unsigned i = 0; i < mEvents.Length(); ++i) { |
|
533 if (aEvent.mTime == mEvents[i].mTime) { |
|
534 if (aEvent.mType == mEvents[i].mType) { |
|
535 // If times and types are equal, replace the event |
|
536 mEvents.ReplaceElementAt(i, aEvent); |
|
537 } else { |
|
538 // Otherwise, place the element after the last event of another type |
|
539 do { |
|
540 ++i; |
|
541 } while (i < mEvents.Length() && |
|
542 aEvent.mType != mEvents[i].mType && |
|
543 aEvent.mTime == mEvents[i].mTime); |
|
544 mEvents.InsertElementAt(i, aEvent); |
|
545 } |
|
546 return; |
|
547 } |
|
548 // Otherwise, place the event right after the latest existing event |
|
549 if (aEvent.mTime < mEvents[i].mTime) { |
|
550 mEvents.InsertElementAt(i, aEvent); |
|
551 return; |
|
552 } |
|
553 } |
|
554 |
|
555 // If we couldn't find a place for the event, just append it to the list |
|
556 mEvents.AppendElement(aEvent); |
|
557 } |
|
558 |
|
559 private: |
|
560 // This is a sorted array of the events in the timeline. Queries of this |
|
561 // data structure should probably be more frequent than modifications to it, |
|
562 // and that is the reason why we're using a simple array as the data structure. |
|
563 // We can optimize this in the future if the performance of the array ends up |
|
564 // being a bottleneck. |
|
565 nsTArray<AudioTimelineEvent> mEvents; |
|
566 float mValue; |
|
567 // This is the value of this AudioParam we computed at the last call. |
|
568 float mComputedValue; |
|
569 // This is the value of this AudioParam at the last tick of the previous event. |
|
570 float mLastComputedValue; |
|
571 }; |
|
572 |
|
573 } |
|
574 } |
|
575 |
|
576 #endif |
|
577 |