Skip to main content

rust_tts_wrapper/
engine.rs

1//! Core TTS engine trait.
2
3use crate::types::{SpeakOptions, TtsResult, Voice, WordBoundary};
4use std::fmt;
5
6/// Callback for streaming audio chunks.
7pub type OnAudioCallback<'a> = &'a mut dyn FnMut(&[u8]);
8
9/// Callback for word boundary events.
10/// Signature: (word, start_sec, end_sec, byte_offset, byte_len, estimated)
11/// `byte_offset` is a byte index into the text as submitted (for
12/// SpeechMarkdown input, the caller's original source — highlighting
13/// targets the caller's string) and `byte_len` a byte length. When a word cannot be located (normalization mismatch,
14/// punctuation artifact), the callback receives the last known offset
15/// held (0 if nothing matched yet) with `byte_len` -1 — not a -1 offset.
16/// `estimated` is true for proportional estimates (unpatched voices,
17/// wpm model), false for measured timings (cloud provider timings).
18pub type OnBoundaryCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32, i32, bool);
19
20/// Callback for SSML mark/bookmark events.
21/// Signature: (name, start_sec, end_sec, char_offset)
22/// char_offset is -1 when the engine doesn't report it. Engines that
23/// estimated) audio position; consumers map it to their bookmark event
24/// (SAPI `SPEI_TTS_BOOKMARK`, SSIP index marks).
25pub type OnMarkCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32);
26
27/// Callback for speech-started events.
28pub type OnStartCallback<'a> = &'a mut dyn FnMut();
29
30/// Callback for speech-finished events.
31pub type OnEndCallback<'a> = &'a mut dyn FnMut();
32
33/// Callback for error events.
34pub type OnErrorCallback<'a> = &'a mut dyn FnMut(&str);
35
36/// Convert SpeechMarkdown to SSML when detected, otherwise return the text
37/// unchanged. Returns `(processed_text, is_ssml)`.
38///
39/// `platform` picks the SSML flavour:
40///   - `"azure"` → MicrosoftAzure
41///   - `"google"` → GoogleAssistant
42///   - `"elevenlabs"` → ElevenLabs pre-v3 prompt markup (`<break>` tags,
43///     no SSML document). Not SSML: `is_ssml` is returned false so engines
44///     pass the markup through instead of stripping it.
45///   - `"elevenlabs-v3"` → Eleven v3 audio-tag dialect (`[whispers]`,
46///     `[pause]`, native slash IPA). Eleven v3 parses no SSML at all.
47///   - `"sapi"` / `"avsynth"` / anything else → AmazonAlexa (the closest
48///     generic SSML baseline; SAPI's own parser accepts the subset that
49///     speechmarkdown-rust emits for Alexa)
50///
51/// Available whenever the `speechmarkdown` feature is on (auto-enabled by
52/// `cloud`, `sapi`, and `avsynth`). Without it, this is a no-op stub.
53#[cfg(feature = "speechmarkdown")]
54#[must_use]
55pub fn preprocess_speech_markdown(text: &str, platform: &str) -> (String, bool) {
56    use speechmarkdown_rust::{Platform, SpeechMarkdownParser};
57
58    // If the input is already SSML (starts with <speak), pass it through
59    // unchanged and flag it so the engine knows not to escape/wrap it.
60    if text.trim_start().to_ascii_lowercase().starts_with("<speak") {
61        return (text.to_string(), true);
62    }
63
64    if !SpeechMarkdownParser::is_speech_markdown(text) {
65        return (text.to_string(), false);
66    }
67
68    let platform = match platform {
69        // Edge speaks the Azure SSML dialect (same Speech platform; it is
70        // not an Alexa-family endpoint) — its free endpoint just lacks a
71        // few elements, which the engine boundary strips.
72        "azure" | "edge" => Platform::MicrosoftAzure,
73        "google" => Platform::GoogleAssistant,
74        // ElevenLabs prompt markup, not SSML: the dialects must reach the
75        // API verbatim (stripping would drop every break/tag, and v3
76        // models read stray XML aloud).
77        "elevenlabs" => Platform::ElevenLabs,
78        "elevenlabs-v3" => Platform::ElevenLabsV3,
79        // Gemini 3.8 TTS is likewise a prompt dialect: angle-bracket
80        // vocal bursts/pauses and CAPS emphasis — no SSML document.
81        "gemini" => Platform::Gemini,
82
83        // Everything else falls back to the Alexa-baseline SSML dialect.
84        _ => Platform::AmazonAlexa,
85    };
86
87    let is_prompt_dialect = matches!(
88        platform,
89        Platform::ElevenLabs | Platform::ElevenLabsV3 | Platform::Gemini
90    );
91
92    match SpeechMarkdownParser::to_ssml(text, platform) {
93        // The prompt dialects are text, not SSML: flag them so engines
94        // send the string as-is rather than treating it as SSML.
95        Ok(ssml) => (ssml, !is_prompt_dialect),
96        Err(_) => (text.to_string(), false),
97    }
98}
99
100#[cfg(not(feature = "speechmarkdown"))]
101#[must_use]
102pub fn preprocess_speech_markdown(text: &str, _platform: &str) -> (String, bool) {
103    (text.to_string(), false)
104}
105
106/// Strip all XML/SSML tags from `ssml`, returning the plain-text content with
107/// collapsed whitespace. Used to convert incoming W3C SSML (from
108/// `tts_speak_ssml`) to plain text for engines that don't accept SSML
109/// (OpenAI, ElevenLabs, SherpaOnnx, etc.).
110#[must_use]
111pub fn strip_ssml_to_text(ssml: &str) -> String {
112    let mut out = String::with_capacity(ssml.len());
113    let mut in_tag = false;
114    for ch in ssml.chars() {
115        match ch {
116            '<' => in_tag = true,
117            '>' => in_tag = false,
118            c if !in_tag => out.push(c),
119            _ => {}
120        }
121    }
122    out.split_whitespace().collect::<Vec<_>>().join(" ")
123}
124
125/// Extract the `name` attribute from a `<voice name='...'>` (or `"..."`) tag
126/// and return the inner SSML content wrapped in a bare `<speak>` element.
127///
128/// When the C++ adapter calls `tts_speak_ssml` it wraps content in
129/// `<speak version='...' xmlns='...'><voice name='...'>INNER</voice></speak>`.
130/// Google/Watson/Polly don't use the `<voice>` wrapper (voice is set via API
131/// config), so this strips it and returns `(voice_name, "<speak>INNER</speak>")`.
132///
133/// Returns `(None, original)` when no `<voice>` tag is found.
134#[must_use]
135pub fn unwrap_voice_tag(ssml: &str) -> (Option<String>, String) {
136    // Find <voice ...> opening tag.
137    let Some(open_idx) = ssml.find("<voice ") else {
138        return (None, ssml.to_string());
139    };
140    let Some(tag_offset) = ssml[open_idx..].find('>') else {
141        return (None, ssml.to_string());
142    };
143    let tag_end = open_idx + tag_offset;
144    let tag_str = &ssml[open_idx..=tag_end];
145
146    let voice_name = extract_name_attr(tag_str);
147
148    // Content between the voice tag's `>` and `</voice>`.
149    let content_start = tag_end + 1;
150    let content_end = ssml.find("</voice>").unwrap_or(ssml.len());
151    let inner = &ssml[content_start..content_end];
152
153    (voice_name, format!("<speak>{inner}</speak>"))
154}
155
156/// Pull `name='value'` or `name="value"` out of an XML tag string.
157fn extract_name_attr(tag: &str) -> Option<String> {
158    for quote in ['\'', '"'] {
159        let needle = format!("name={quote}");
160        if let Some(start) = tag.find(&needle) {
161            let val_start = start + needle.len();
162            if let Some(end) = tag[val_start..].find(quote) {
163                return Some(tag[val_start..val_start + end].to_string());
164            }
165        }
166    }
167    None
168}
169
170/// Trait that every TTS engine must implement.
171///
172/// Mirrors Swift's `TTSClient` protocol.
173#[allow(clippy::missing_errors_doc)]
174pub trait TtsEngine: Send + Sync + fmt::Debug {
175    /// Start speaking `text` asynchronously.
176    #[allow(clippy::too_many_arguments)]
177    #[allow(clippy::too_many_arguments)]
178    fn speak(
179        &self,
180        text: &str,
181        voice: Option<&str>,
182        rate: f32,
183        pitch: f32,
184        volume: f32,
185        on_audio: Option<OnAudioCallback>,
186        on_boundary: Option<OnBoundaryCallback>,
187        on_mark: Option<OnMarkCallback>,
188    ) -> TtsResult<()>;
189
190    /// Speak with full [`SpeakOptions`], matching Swift's `speak(_:options:)`.
191    fn speak_with_options(
192        &self,
193        text: &str,
194        options: Option<&SpeakOptions>,
195        on_audio: Option<OnAudioCallback>,
196        on_boundary: Option<OnBoundaryCallback>,
197        on_mark: Option<OnMarkCallback>,
198    ) -> TtsResult<()> {
199        let opts = options.cloned().unwrap_or_default();
200        self.speak(
201            text,
202            opts.voice.as_deref(),
203            opts.effective_rate(),
204            opts.effective_pitch(),
205            opts.effective_volume(),
206            on_audio,
207            on_boundary,
208            on_mark,
209        )
210    }
211
212    /// Speak `text` synchronously, blocking until synthesis completes.
213    #[allow(clippy::too_many_arguments)]
214    #[allow(clippy::too_many_arguments)]
215    fn speak_sync(
216        &self,
217        text: &str,
218        voice: Option<&str>,
219        rate: f32,
220        pitch: f32,
221        volume: f32,
222        on_audio: Option<OnAudioCallback>,
223        on_boundary: Option<OnBoundaryCallback>,
224        on_mark: Option<OnMarkCallback>,
225    ) -> TtsResult<()>;
226
227    /// Stop any in-progress speech.
228    fn stop(&self) -> TtsResult<()>;
229
230    /// Pause speech (default: no-op, engines may override).
231    fn pause(&self) -> TtsResult<()> {
232        Ok(())
233    }
234
235    /// Resume speech (default: no-op, engines may override).
236    fn resume(&self) -> TtsResult<()> {
237        Ok(())
238    }
239
240    /// List available voices for this engine.
241    fn get_voices(&self) -> TtsResult<Vec<Voice>>;
242
243    /// Return the unique identifier of this engine (e.g. `"system"`, `"sherpaonnx"`).
244    fn engine_id(&self) -> &'static str;
245
246    /// Check whether the configured credentials are valid.
247    /// Default: attempt to fetch voices as a validation.
248    fn check_credentials(&self) -> TtsResult<bool> {
249        match self.get_voices() {
250            Ok(_) => Ok(true),
251            Err(_) => Ok(false),
252        }
253    }
254
255    /// Synthesize text to audio bytes (full buffer, no playback).
256    /// Mirrors Swift's `synthToBytes(_:options:)`.
257    fn synth_to_bytes(
258        &self,
259        text: &str,
260        voice: Option<&str>,
261        rate: f32,
262        pitch: f32,
263        volume: f32,
264    ) -> TtsResult<Vec<u8>> {
265        let mut buf = Vec::new();
266        self.speak(
267            text,
268            voice,
269            rate,
270            pitch,
271            volume,
272            Some(&mut |chunk: &[u8]| {
273                buf.extend_from_slice(chunk);
274            }),
275            None,
276            None,
277        )?;
278        Ok(buf)
279    }
280
281    /// Synthesize with [`SpeakOptions`].
282    fn synth_to_bytes_with_options(
283        &self,
284        text: &str,
285        options: Option<&SpeakOptions>,
286    ) -> TtsResult<Vec<u8>> {
287        let opts = options.cloned().unwrap_or_default();
288        self.synth_to_bytes(
289            text,
290            opts.voice.as_deref(),
291            opts.effective_rate(),
292            opts.effective_pitch(),
293            opts.effective_volume(),
294        )
295    }
296
297    /// Synthesize text and return word boundary information.
298    fn synth_with_boundaries(
299        &self,
300        text: &str,
301        voice: Option<&str>,
302        rate: f32,
303        pitch: f32,
304        volume: f32,
305    ) -> TtsResult<(Vec<u8>, Vec<WordBoundary>)> {
306        let audio = self.synth_to_bytes(text, voice, rate, pitch, volume)?;
307        let boundaries = estimate_word_boundaries(text);
308        Ok((audio, boundaries))
309    }
310}
311
312/// Estimate word boundaries using word-length-adjusted timing with an
313/// inter-sentence pause. Originally mirroring Swift's
314/// `WordTimingEstimator`, since extended (sentence gaps) beyond it.
315#[must_use]
316#[allow(clippy::cast_precision_loss)]
317pub fn estimate_word_boundaries(text: &str) -> Vec<WordBoundary> {
318    estimate_word_boundaries_with_wpm(text, 150.0)
319}
320
321/// Estimate word boundaries with configurable words per minute.
322/// Abbreviation caveat: token-level detection means "Mr." / "e.g." also
323/// get the sentence gap — an accepted trade-off for an estimator.
324#[must_use]
325#[allow(clippy::cast_precision_loss)]
326pub fn estimate_word_boundaries_with_wpm(text: &str, words_per_minute: f64) -> Vec<WordBoundary> {
327    // Weighted model: per-word duration tracks syllable count (a far
328    // better duration proxy than character length — "strength" is one
329    // syllable, "audio" is three), plus tiered pauses after punctuation.
330    // Calibration: average English ≈ 1.55 syllables/word, so
331    // ms_per_syllable = 60_000 / (wpm × 1.55) reproduces the old wpm.
332    // Sentence gap stays a flat 350 ms (existing estimator contract —
333    // inline punctuation gets no gap here; the tiered beat model lives
334    // in weighted_boundaries_scaled where a real duration is known).
335    const SENTENCE_GAP_MS: u64 = 350;
336
337    #[allow(clippy::cast_precision_loss)]
338    let ms_per_syllable = 60_000.0 / (words_per_minute * 1.55);
339
340    let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
341    if words.is_empty() {
342        return Vec::new();
343    }
344
345    let mut boundaries: Vec<WordBoundary> = Vec::with_capacity(words.len());
346    let mut current_ms: u64 = 0;
347
348    for (i, word) in words.iter().enumerate() {
349        if i > 0 && ends_sentence(words[i - 1]) {
350            current_ms += SENTENCE_GAP_MS;
351        }
352        let duration = ((word_weight(word) * ms_per_syllable) as u64).max(1);
353        boundaries.push(WordBoundary {
354            text: (*word).to_string(),
355            offset: current_ms,
356            duration,
357            estimated: true,
358        });
359        current_ms += duration;
360    }
361
362    boundaries
363}
364
365/// Rough syllable count for duration weighting: contiguous vowel groups
366/// (English-centric), every digit spoken individually, minimum one.
367#[must_use]
368pub fn syllable_count(word: &str) -> usize {
369    let mut count = 0usize;
370    let mut in_vowel_run = false;
371    for ch in word.chars() {
372        if ch.is_ascii_digit() {
373            // digits read individually ("2026" -> four syllables)
374            count += 1;
375            in_vowel_run = false;
376        } else {
377            let vowel = matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y');
378            if vowel && !in_vowel_run {
379                count += 1;
380            }
381            in_vowel_run = vowel;
382        }
383    }
384    count.max(1)
385}
386
387/// Duration weight of a word in syllable-equivalents: syllables plus a
388/// small constant for onsets/codas so "a" is not zero-length.
389#[must_use]
390pub fn word_weight(word: &str) -> f64 {
391    let bare = word.trim_matches(|c: char| !c.is_alphanumeric());
392    f64::from(syllable_count(bare) as u32) + 0.3
393}
394
395/// Pause weight AFTER a word, in syllable-equivalents, from its trailing
396/// punctuation. Sentence ends breathe longest; commas/semicolons beat.
397#[must_use]
398pub fn trailing_pause_weight(word: &str) -> f64 {
399    // look past closing quotes/brackets, like ends_sentence()
400    let trimmed = word
401        .trim_end()
402        .trim_end_matches(['"', '\'', '”', '’', ')', ']', '»']);
403    let Some(last) = trimmed.chars().last() else {
404        return 0.0;
405    };
406    match last {
407        '.' | '!' | '?' | '…' => 1.4, // sentence breathe (≥ the 350 ms gap)
408        ';' | ':' | '—' | '–' => 0.75,
409        ',' => 0.55,
410        _ => 0.0,
411    }
412}
413
414/// Word boundaries with RELATIVE durations mapped onto a known total
415/// duration (engines that know the audio length but not per-word times —
416/// e.g. qwen3-local). The word shape comes from the syllable/pause
417/// weights; the timeline is linearly rescaled so the last word ends
418/// exactly at `total_secs`. Offsets/durations are absolute seconds.
419#[must_use]
420#[allow(clippy::cast_precision_loss)]
421pub fn weighted_boundaries_scaled(text: &str, total_secs: f32) -> Vec<WordBoundary> {
422    let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
423    if words.is_empty() || total_secs <= 0.0 {
424        return Vec::new();
425    }
426
427    // cumulative weight marks: word i occupies (after_i - before_i)
428    let mut before: Vec<f64> = Vec::with_capacity(words.len());
429    let mut after: Vec<f64> = Vec::with_capacity(words.len());
430    let mut cum = 0.0f64;
431    for (i, word) in words.iter().enumerate() {
432        if i > 0 {
433            cum += trailing_pause_weight(words[i - 1]);
434        }
435        before.push(cum);
436        cum += word_weight(word);
437        after.push(cum);
438    }
439    let total_w = cum.max(1e-9);
440
441    words
442        .iter()
443        .enumerate()
444        .map(|(i, word)| {
445            let start = f64::from(total_secs) * before[i] / total_w;
446            let end = f64::from(total_secs) * after[i] / total_w;
447            WordBoundary {
448                text: (*word).to_string(),
449                offset: (start * 1000.0) as u64,
450                duration: (((end - start) * 1000.0) as u64).max(1),
451                estimated: true,
452            }
453        })
454        .collect()
455}
456
457/// Whether `word` ends a sentence for estimation purposes: terminal
458/// sentence punctuation (possibly before a closing quote or bracket).
459/// Semicolons, colons and commas stay inline — they read as short beats,
460/// not sentence breaks.
461fn ends_sentence(word: &str) -> bool {
462    let trimmed = word.trim_end_matches(['"', '\'', ')', ']', '”', '’']);
463    trimmed.ends_with('.')
464        || trimmed.ends_with('!')
465        || trimmed.ends_with('?')
466        || trimmed.ends_with('…')
467}
468
469#[cfg(test)]
470mod tests {
471    #[test]
472    fn estimator_adds_sentence_gap_between_sentences() {
473        let b = estimate_word_boundaries("One. Two words here.");
474        assert_eq!(b.len(), 4);
475        assert_eq!(b[0].offset, 0);
476        // Without a gap, word 2 would start exactly after word 1's
477        // duration; with it, at least SENTENCE_GAP_MS later.
478        assert!(b[1].offset >= b[0].offset + b[0].duration + 350);
479        // No gap inside the second sentence (word 3 follows word 2
480        // directly).
481        assert_eq!(b[2].offset, b[1].offset + b[1].duration);
482    }
483
484    #[test]
485    fn estimator_no_gap_without_sentence_punctuation() {
486        let b = estimate_word_boundaries("one two three");
487        for w in b.windows(2) {
488            assert_eq!(w[1].offset, w[0].offset + w[0].duration);
489        }
490    }
491
492    #[test]
493    fn estimator_gap_applies_before_closing_quote() {
494        let b = estimate_word_boundaries("He said \"stop.\" Then left.");
495        // "Then" follows the quoted sentence end: gap applied.
496        let then = &b[3];
497        let stop = &b[2];
498        assert!(then.offset >= stop.offset + stop.duration + 350);
499    }
500
501    #[test]
502    fn estimator_inline_punctuation_gets_no_gap() {
503        let b = estimate_word_boundaries("first, second; third:");
504        for w in b.windows(2) {
505            assert_eq!(w[1].offset, w[0].offset + w[0].duration);
506        }
507    }
508
509    use super::*;
510
511    #[test]
512    fn test_strip_ssml_to_text_basic() {
513        assert_eq!(
514            strip_ssml_to_text("<speak>Hello world</speak>"),
515            "Hello world"
516        );
517    }
518
519    #[test]
520    fn test_strip_ssml_to_text_with_tags() {
521        let ssml = "<speak><voice name='en-US-Aria'>Hello <phoneme alphabet='ipa' ph='wɜːld'>world</phoneme></voice></speak>";
522        assert_eq!(strip_ssml_to_text(ssml), "Hello world");
523    }
524
525    #[test]
526    fn test_strip_ssml_to_text_collapses_whitespace() {
527        assert_eq!(
528            strip_ssml_to_text("<speak>\n  Hello\n  world\n</speak>"),
529            "Hello world"
530        );
531    }
532
533    #[test]
534    fn test_strip_ssml_to_text_plain_passthrough() {
535        assert_eq!(strip_ssml_to_text("just plain text"), "just plain text");
536    }
537
538    #[test]
539    fn test_unwrap_voice_tag_extracts_voice_and_inner() {
540        let ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis'>\
541                    <voice name='en-US-Wavenet-D'>Hello <prosody rate='+20%'>fast</prosody></voice>\
542                    </speak>";
543        let (voice, inner) = unwrap_voice_tag(ssml);
544        assert_eq!(voice.as_deref(), Some("en-US-Wavenet-D"));
545        assert!(inner.starts_with("<speak>"));
546        assert!(inner.contains("Hello"));
547        assert!(inner.contains("<prosody"));
548        assert!(!inner.contains("<voice"));
549    }
550
551    #[test]
552    fn test_unwrap_voice_tag_double_quotes() {
553        let ssml = "<speak><voice name=\"en-US-AriaNeural\">Hi</voice></speak>";
554        let (voice, inner) = unwrap_voice_tag(ssml);
555        assert_eq!(voice.as_deref(), Some("en-US-AriaNeural"));
556        assert!(inner.contains("Hi"));
557    }
558
559    #[test]
560    fn test_unwrap_voice_tag_no_voice_returns_original() {
561        let ssml = "<speak>Hello world</speak>";
562        let (voice, inner) = unwrap_voice_tag(ssml);
563        assert!(voice.is_none());
564        assert_eq!(inner, ssml);
565    }
566}
567
568#[cfg(test)]
569mod weighted_timing_tests {
570    use super::*;
571
572    #[test]
573    fn syllable_counts() {
574        assert_eq!(syllable_count("friend"), 1);
575        assert_eq!(syllable_count("friendship"), 2);
576        assert_eq!(syllable_count("banana"), 3);
577        assert_eq!(syllable_count("strength"), 1);
578        assert_eq!(syllable_count("2026"), 4); // digits read individually
579        assert_eq!(syllable_count("hmm"), 1); // no vowels -> minimum
580    }
581
582    #[test]
583    fn scaled_covers_exactly_and_orders_monotonically() {
584        let b = weighted_boundaries_scaled("Hello, this is a timing test.", 10.0);
585        assert_eq!(b.len(), 6);
586        assert_eq!(b[0].offset, 0);
587        let last = b.last().unwrap();
588        // last word ends at the audio end (10 s)
589        assert!(last.offset + last.duration >= 9_990);
590        for w in b.windows(2) {
591            assert!(w[1].offset >= w[0].offset + w[0].duration - 1);
592        }
593    }
594
595    #[test]
596    fn long_words_get_more_time_than_short() {
597        let b = weighted_boundaries_scaled("a antidisestablishmentarianism", 10.0);
598        let short = b[0].duration;
599        let long = b[1].duration;
600        assert!(long > short * 4, "long {long} vs short {short}");
601    }
602
603    #[test]
604    fn punctuation_beats_pause() {
605        // comma between the words: the gap counts toward neither word
606        let b = weighted_boundaries_scaled("Hello, world", 10.0);
607        let gap = b[1].offset - (b[0].offset + b[0].duration);
608        assert!(gap > 10, "expected comma pause, got {gap} ms");
609        // and a comma pause is shorter than a sentence pause
610        let s = weighted_boundaries_scaled("Hello. World", 10.0);
611        let sgap = s[1].offset - (s[0].offset + s[0].duration);
612        assert!(sgap > gap);
613    }
614
615    #[test]
616    fn estimator_long_word_longer_than_short() {
617        let b = estimate_word_boundaries("strength audio");
618        // 1 syllable vs 3: the weighted model must reflect that
619        assert!(b[1].duration > b[0].duration);
620    }
621}