praatio.data_classes.interval_tier

An IntervalTier is a tier containing an array of intervals -- data that spans a period of time

  1"""
  2An IntervalTier is a tier containing an array of intervals -- data that spans a period of time
  3"""
  4from typing import Callable, List, Optional, Tuple, Sequence
  5
  6from typing_extensions import Literal
  7
  8
  9from praatio.utilities.constants import (
 10    Interval,
 11    INTERVAL_TIER,
 12    CropCollision,
 13)
 14
 15from praatio.utilities import errors
 16from praatio.utilities import utils
 17from praatio.utilities import my_math
 18from praatio.utilities import constants
 19
 20from praatio.data_classes import textgrid_tier
 21
 22
 23def _homogenizeEntries(entries):
 24    """
 25    Enforces consistency in intervals
 26
 27    - converts all entries to intervals
 28    - removes whitespace in labels
 29    - sorts values by time
 30    """
 31    processedEntries = [
 32        Interval(float(start), float(end), label.strip())
 33        for start, end, label in entries
 34    ]
 35    processedEntries.sort()
 36    return processedEntries
 37
 38
 39def _calculateMinAndMaxTime(entries: Sequence[Interval], minT=None, maxT=None):
 40    minTimeList = [interval.start for interval in entries]
 41    maxTimeList = [interval.end for interval in entries]
 42
 43    if minT is not None:
 44        minTimeList.append(float(minT))
 45    if maxT is not None:
 46        maxTimeList.append(float(maxT))
 47
 48    try:
 49        resolvedMinT = min(minTimeList)
 50        resolvedMaxT = max(maxTimeList)
 51    except ValueError:
 52        raise errors.TimelessTextgridTierException()
 53
 54    return (resolvedMinT, resolvedMaxT)
 55
 56
 57class IntervalTier(textgrid_tier.TextgridTier):
 58    tierType = INTERVAL_TIER
 59    entryType = Interval
 60
 61    def __init__(
 62        self,
 63        name: str,
 64        entries: List[Interval],
 65        minT: Optional[float] = None,
 66        maxT: Optional[float] = None,
 67    ):
 68        """An interval tier is for annotating events that have duration
 69
 70        The entries is of the form:
 71        [(startTime1, endTime1, label1), (startTime2, endTime2, label2), ]
 72
 73        The data stored in the labels can be anything but will
 74        be interpreted as text by praatio (the label could be descriptive
 75        text e.g. ('erase this region') or numerical data e.g. (average pitch
 76        values like '132'))
 77        """
 78        entries = _homogenizeEntries(entries)
 79        calculatedMinT, calculatedMaxT = _calculateMinAndMaxTime(entries, minT, maxT)
 80
 81        super(IntervalTier, self).__init__(
 82            name, entries, calculatedMinT, calculatedMaxT
 83        )
 84        self._validate()
 85
 86    def _validate(self):
 87        """An interval tier is invalid if the entries are out of order or overlap with each other"""
 88        for entry in self.entries:
 89            if entry.start >= entry.end:
 90                raise errors.TextgridStateError(
 91                    f"The start time of an interval ({entry.start}) "
 92                    f"cannot occur after its end time ({entry.end})"
 93                )
 94
 95        for entry, nextEntry in zip(self.entries[0::], self.entries[1::]):
 96            if entry.end > nextEntry.start:
 97                raise errors.TextgridStateError(
 98                    "Two intervals in the same tier overlap in time:\n"
 99                    f"({entry.start}, {entry.end}, {entry.label}) and "
100                    f"({nextEntry.start}, {nextEntry.end}, {nextEntry.label})"
101                )
102
103    @property
104    def timestamps(self) -> List[float]:
105        """All unique timestamps used in this tier"""
106        tmpTimestamps = [
107            time
108            for start, stop, _ in self.entries
109            for time in [
110                start,
111                stop,
112            ]
113        ]
114
115        uniqueTimestamps = list(set(tmpTimestamps))
116        uniqueTimestamps.sort()
117
118        return uniqueTimestamps
119
120    def crop(
121        self,
122        cropStart: float,
123        cropEnd: float,
124        mode: Literal["strict", "lax", "truncated"],
125        rebaseToZero: bool,
126    ) -> "IntervalTier":
127        """Creates a new tier with all entries that fit inside the new interval
128
129        Args:
130            cropStart:
131            cropEnd:
132            mode: determines cropping behavior
133                - 'strict', only intervals wholly contained by the crop
134                    interval will be kept
135                - 'lax', partially contained intervals will be kept
136                - 'truncated', partially contained intervals will be
137                    truncated to fit within the crop region.
138            rebaseToZero: if True, the cropped textgrid values
139                will be subtracted by the cropStart
140
141        Returns:
142            the modified version of the current tier
143        """
144
145        utils.validateOption("mode", mode, CropCollision)
146
147        if cropStart >= cropEnd:
148            raise errors.ArgumentError(
149                f"Crop error: start time ({cropStart}) must occur before end time ({cropEnd})"
150            )
151
152        newEntryList = utils.getIntervalsInInterval(
153            cropStart, cropEnd, self.entries, mode
154        )
155
156        if rebaseToZero is True:
157            newSmallestValue = newEntryList[0][0]
158            if newSmallestValue < cropStart:
159                timeDiff = newSmallestValue
160            else:
161                timeDiff = cropStart
162            newEntryList = [
163                Interval(start - timeDiff, end - timeDiff, label)
164                for start, end, label in newEntryList
165            ]
166            minT = 0.0
167            maxT = cropEnd - cropStart
168        else:
169            minT = cropStart
170            maxT = cropEnd
171
172        croppedTier = IntervalTier(self.name, newEntryList, minT, maxT)
173
174        return croppedTier
175
176    def dejitter(
177        self,
178        referenceTier: textgrid_tier.TextgridTier,
179        maxDifference: float = 0.001,
180    ) -> textgrid_tier.TextgridTier:
181        """
182        Set timestamps in this tier to be the same as values in the reference tier
183
184        Timestamps will only be moved if they are less than maxDifference away from the
185        reference time.
186
187        This can be used to correct minor alignment errors between tiers, as made when
188        annotating files manually, etc.
189
190        Args:
191            referenceTier: the IntervalTier or PointTier to use as a reference
192            maxDifference: the maximum amount to allow timestamps to be moved by
193
194        Returns:
195            the modified version of the current tier
196        """
197        referenceTimestamps = referenceTier.timestamps
198
199        newEntries = []
200        for start, stop, label in self.entries:
201            startCompare = min(referenceTimestamps, key=lambda x: abs(x - start))
202            stopCompare = min(referenceTimestamps, key=lambda x: abs(x - stop))
203
204            if my_math.lessThanOrEqual(abs(start - startCompare), maxDifference):
205                start = startCompare
206            if my_math.lessThanOrEqual(abs(stop - stopCompare), maxDifference):
207                stop = stopCompare
208            newEntries.append((start, stop, label))
209
210        return self.new(entries=newEntries)
211
212    def deleteEntry(self, entry: Interval) -> None:
213        """Removes an entry from the entries"""
214        self._entries.pop(self._entries.index(entry))
215
216    def difference(self, tier: "IntervalTier") -> "IntervalTier":
217        """Takes the set difference of this tier and the given one
218
219        Any overlapping portions of entries with entries in this textgrid
220        will be removed from the returned tier.
221
222        Args:
223            tier: the tier to subtract from this one
224
225        Returns:
226            the modified version of the current tier
227        """
228        retTier = self.new()
229
230        for entry in tier.entries:
231            retTier = retTier.eraseRegion(
232                entry.start,
233                entry.end,
234                collisionMode=constants.EraseCollision.TRUNCATE,
235                doShrink=False,
236            )
237
238        return retTier
239
240    def editTimestamps(
241        self,
242        offset: float,
243        reportingMode: Literal["silence", "warning", "error"] = "warning",
244    ) -> "IntervalTier":
245        """Modifies all timestamps by a constant amount
246
247        Args:
248            offset: the amount to shift all intervals
249            reportingMode: Determines the behavior if an entries moves outside
250                of minTimestamp or maxTimestamp after being edited
251
252        Returns:
253            the modified version of the current tier
254        """
255        utils.validateOption(
256            "reportingMode", reportingMode, constants.ErrorReportingMode
257        )
258        errorReporter = utils.getErrorReporter(reportingMode)
259
260        newEntryList = []
261        for interval in self.entries:
262            newStart = offset + interval.start
263            newEnd = offset + interval.end
264
265            utils.checkIsUndershoot(newStart, self.minTimestamp, errorReporter)
266            utils.checkIsOvershoot(newEnd, self.maxTimestamp, errorReporter)
267
268            if newEnd <= 0:
269                continue
270            if newStart < 0:
271                newStart = 0
272
273            newEntryList.append(Interval(newStart, newEnd, interval.label))
274
275        # Determine new min and max timestamps
276        newMin = min([interval.start for interval in newEntryList])
277        newMax = max([interval.end for interval in newEntryList])
278
279        if newMin > self.minTimestamp:
280            newMin = self.minTimestamp
281
282        if newMax < self.maxTimestamp:
283            newMax = self.maxTimestamp
284
285        return IntervalTier(self.name, newEntryList, newMin, newMax)
286
287    def eraseRegion(
288        self,
289        start: float,
290        end: float,
291        collisionMode: Literal["truncate", "categorical", "error"] = "error",
292        doShrink: bool = True,
293    ) -> "IntervalTier":
294        """Makes a region in a tier blank (removes all contained entries)
295
296        Args:
297            start:
298            end:
299            collisionMode: Determines the behavior when the region to erase
300                overlaps with existing intervals.
301                - 'truncate' partially contained entries will have the portion
302                    removed that overlaps with the target entry
303                - 'categorical' all entries that overlap, even partially, with
304                    the target entry will be completely removed
305                - None or any other value throws IntervalCollision
306            doShrink: If True, moves leftward by (/end/ - /start/)
307                amount, each item that occurs after /end/
308
309        Returns:
310            The modified version of the current tier
311
312        Raises:
313            CollisionError
314        """
315        utils.validateOption("collisionMode", collisionMode, constants.EraseCollision)
316
317        matchList = self.crop(start, end, CropCollision.LAX, False).entries
318        newTier = self.new()
319
320        if len(matchList) == 0:
321            pass
322        else:
323            if collisionMode == constants.EraseCollision.ERROR:
324                raise errors.CollisionError(
325                    f"Erase region ({start}, {end})overlapped with an interval. "
326                    "If this was expected, consider setting the collisionMode"
327                )
328
329            # Remove all the matches from the entries
330            # Go in reverse order because we're destructively altering
331            # the order of the list (messes up index order)
332            for interval in matchList[::-1]:
333                newTier.deleteEntry(interval)
334
335            # If we're only truncating, reinsert entries on the left and
336            # right edges
337            # if categorical, it doesn't make it into the list at all
338            if collisionMode == constants.EraseCollision.TRUNCATE:
339                # Check left edge
340                if matchList[0].start < start:
341                    newEntry = Interval(matchList[0].start, start, matchList[0].label)
342                    newTier.insertEntry(newEntry)
343
344                # Check right edge
345                if matchList[-1].end > end:
346                    newEntry = Interval(end, matchList[-1].end, matchList[-1].label)
347                    newTier.insertEntry(newEntry)
348
349        if doShrink is True:
350            diff = end - start
351            newEntryList = []
352            for interval in newTier.entries:
353                if interval.end <= start:
354                    newEntryList.append(interval)
355                elif interval.start >= end:
356                    newEntryList.append(
357                        Interval(
358                            interval.start - diff, interval.end - diff, interval.label
359                        )
360                    )
361
362            # Special case: an interval that spanned the deleted
363            # section
364            for i in range(0, len(newEntryList) - 1):
365                rightEdge = newEntryList[i].end == start
366                leftEdge = newEntryList[i + 1].start == start
367                sameLabel = newEntryList[i].label == newEntryList[i + 1].label
368                if rightEdge and leftEdge and sameLabel:
369                    newInterval = Interval(
370                        newEntryList[i].start,
371                        newEntryList[i + 1].end,
372                        newEntryList[i].label,
373                    )
374
375                    newEntryList.pop(i + 1)
376                    newEntryList.pop(i)
377                    newEntryList.insert(i, newInterval)
378
379                    # Only one interval can span the deleted section,
380                    # so if we've found it, move on
381                    break
382
383            newMax = newTier.maxTimestamp - diff
384            newTier = newTier.new(entries=newEntryList, maxTimestamp=newMax)
385
386        return newTier
387
388    def getValuesInIntervals(self, dataTupleList: List) -> List[Tuple[Interval, List]]:
389        """Returns data from dataTupleList contained in labeled intervals
390
391        Each labeled interval will get its own list of data values.
392
393        dataTupleList should be of the form:
394        [(time1, value1a, value1b,...), (time2, value2a, value2b...), ...]
395        """
396
397        returnList = []
398
399        for interval in self.entries:
400            intervalDataList = utils.getValuesInInterval(
401                dataTupleList, interval.start, interval.end
402            )
403            returnList.append((interval, intervalDataList))
404
405        return returnList
406
407    def getNonEntries(self) -> List[Interval]:
408        """Returns the regions of the textgrid without labels
409
410        This can include unlabeled segments and regions marked as silent.
411        """
412        entries = self.entries
413        invertedEntryList = [
414            Interval(entries[i].end, entries[i + 1].start, "")
415            for i in range(len(entries) - 1)
416        ]
417
418        # Remove entries that have no duration (ie lie between two entries
419        # that share a border)
420        invertedEntryList = [
421            interval for interval in invertedEntryList if interval.start < interval.end
422        ]
423
424        if entries[0].start > 0:
425            invertedEntryList.insert(0, Interval(0, entries[0].start, ""))
426
427        if entries[-1].end < self.maxTimestamp:
428            invertedEntryList.append(Interval(entries[-1].end, self.maxTimestamp, ""))
429
430        invertedEntryList = [
431            interval if isinstance(interval, Interval) else Interval(*interval)
432            for interval in invertedEntryList
433        ]
434
435        return invertedEntryList
436
437    def insertEntry(
438        self,
439        entry: Interval,
440        collisionMode: Literal["replace", "merge", "error"] = "error",
441        collisionReportingMode: Literal["silence", "warning"] = "warning",
442    ) -> None:
443        """Inserts an interval into the tier
444
445        Args:
446            entry: the Interval to insert
447            collisionMode: determines the behavior in the event that intervals
448                exist in the insertion area.
449                - 'replace' will remove existing items
450                - 'merge' will fuse the inserting item with existing items
451                - None or any other value will throw a CollisionError
452            collisionReportingMode: Determines the behavior if the new entry
453                overlaps with an existing one
454
455        Returns:
456            the modified version of the current tier
457        """
458        utils.validateOption(
459            "collisionMode", collisionMode, constants.IntervalCollision
460        )
461        utils.validateOption(
462            "collisionReportingMode",
463            collisionReportingMode,
464            constants.ErrorReportingMode,
465        )
466        collisionReporter = utils.getErrorReporter(collisionReportingMode)
467
468        if not isinstance(entry, Interval):
469            interval = Interval(*entry)
470        else:
471            interval = entry
472
473        matchList = self.crop(
474            interval.start, interval.end, CropCollision.LAX, False
475        )._entries
476
477        if len(matchList) == 0:
478            self._entries.append(interval)
479
480        elif collisionMode == constants.IntervalCollision.REPLACE:
481            for matchEntry in matchList:
482                self.deleteEntry(matchEntry)
483            self._entries.append(interval)
484
485        elif collisionMode == constants.IntervalCollision.MERGE:
486            for matchEntry in matchList:
487                self.deleteEntry(matchEntry)
488            matchList.append(interval)
489            matchList.sort()  # By starting time
490
491            newInterval = Interval(
492                min([tmpInterval.start for tmpInterval in matchList]),
493                max([tmpInterval.end for tmpInterval in matchList]),
494                "-".join([tmpInterval.label for tmpInterval in matchList]),
495            )
496            self._entries.append(newInterval)
497
498        else:
499            raise errors.CollisionError(
500                "Attempted to insert interval "
501                f"({interval.start}, {interval.end}, '{interval.label}') into tier {self.name} "
502                "of textgrid but overlapping entries "
503                f"{[tuple(interval) for interval in matchList]} "
504                "already exist"
505            )
506
507        self.sort()
508
509        if self._entries[0][0] < self.minTimestamp:
510            self.minTimestamp = self._entries[0][0]
511
512        if self._entries[-1][1] > self.maxTimestamp:
513            self.maxTimestamp = self._entries[-1][1]
514
515        if len(matchList) != 0:
516            collisionReporter(
517                errors.CollisionError,
518                f"Collision warning for ({interval}) with items "
519                f"({matchList}) of tier '{self.name}'",
520            )
521
522    def insertSpace(
523        self,
524        start: float,
525        duration: float,
526        collisionMode: Literal["stretch", "split", "no_change", "error"],
527    ) -> "IntervalTier":
528        """Inserts a blank region into the tier
529
530        Args:
531            start:
532            duration:
533            collisionMode: Determines the behavior that occurs if
534                an interval stradles the starting point
535                - 'stretch' stretches the interval by /duration/ amount
536                - 'split' splits the interval into two--everything to the
537                    right of 'start' will be advanced by 'duration' seconds
538                - 'no change' leaves the interval as is with no change
539                - 'error' will stop execution and raise an error
540
541        Returns:
542            the modified version of the current tier
543        """
544        utils.validateOption(
545            "collisionMode", collisionMode, constants.WhitespaceCollision
546        )
547
548        newEntryList = []
549        for interval in self.entries:
550            # Entry exists before the insertion point
551            if interval.end <= start:
552                newEntryList.append(interval)
553            # Entry exists after the insertion point
554            elif interval.start >= start:
555                newEntryList.append(
556                    Interval(
557                        interval.start + duration,
558                        interval.end + duration,
559                        interval.label,
560                    )
561                )
562            # Entry straddles the insertion point
563            elif interval.start <= start and interval.end > start:
564                if collisionMode == constants.WhitespaceCollision.STRETCH:
565                    newEntryList.append(
566                        Interval(
567                            interval.start, interval.end + duration, interval.label
568                        )
569                    )
570                elif collisionMode == constants.WhitespaceCollision.SPLIT:
571                    # Left side of the split
572                    newEntryList.append(Interval(interval.start, start, interval.label))
573                    # Right side of the split
574                    newEntryList.append(
575                        (
576                            start + duration,
577                            start + duration + (interval.end - start),
578                            interval.label,
579                        )
580                    )
581                elif collisionMode == constants.WhitespaceCollision.NO_CHANGE:
582                    newEntryList.append(interval)
583                else:
584                    raise errors.ArgumentError(
585                        f"Collision occured during insertSpace() for interval '{interval}' "
586                        f"and given white space insertion interval ({start}, {start + duration})"
587                    )
588
589        newTier = self.new(
590            entries=newEntryList, maxTimestamp=self.maxTimestamp + duration
591        )
592
593        return newTier
594
595    def intersection(self, tier: "IntervalTier", demarcator="-") -> "IntervalTier":
596        """Takes the set intersection of this tier and the given one
597
598        - The output will contain one interval for each overlapping pair
599          e.g. [(1, 2, 'foo')] and [(1, 1.3, 'bang'), (1.7, 2, 'wizz')]
600                -> [(1, 1.3, 'foo-bang'), (1.7, 2, 'foo-wizz')]
601        - Only intervals that exist in both tiers will remain in the returned tier.
602          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
603                -> [(1, 2, 'foo-bang')]
604        - If intervals partially overlap, only the overlapping portion will be returned.
605          e.g. [(1, 2, 'foo')] and [(0.5, 1.5, 'bang')]
606                -> [(1, 1.5, 'foo-bang')]
607
608        Compare with IntervalTier.mergeLabels
609
610        Args:
611            tier: the tier to intersect with
612            demarcator: the character to separate the labels of the overlapping intervals
613
614        Returns:
615            IntervalTier: the modified version of the current tier
616        """
617        retEntryList = []
618        for interval in tier.entries:
619            subTier = self.crop(
620                interval.start, interval.end, CropCollision.TRUNCATED, False
621            )
622
623            # Combine the labels in the two tiers
624            subEntryList = [
625                (
626                    subInterval.start,
627                    subInterval.end,
628                    f"{subInterval.label}{demarcator}{interval.label}",
629                )
630                for subInterval in subTier.entries
631            ]
632
633            retEntryList.extend(subEntryList)
634
635        newName = f"{self.name}-{tier.name}"
636
637        retTier = self.new(newName, retEntryList)
638
639        return retTier
640
641    def mergeLabels(
642        self, tier: "IntervalTier", demarcator: str = ","
643    ) -> "IntervalTier":
644        """Merges labels of overlapping tiers into this tier
645
646        - All intervals in this tier will appear in the output; for the given tier, only intervals
647          that overlap with content in this tier will appear in the output
648          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
649                -> [(1, 2, 'foo(bang)'), (3, 4, 'bar()')]
650        - If multiple entries exist in a subinterval, their labels will be concatenated
651          e.g. [(1, 2, 'hi')] and [(1, 1.5, 'h'), (1.5, 2, 'ai')] -> [(1, 2, 'hi(h,ai)')]
652
653        compare with IntervalTier.intersection
654
655        Args:
656            tier: the tier to intersect with
657            demarcator: the string to separate items that fall in the same subinterval
658
659        Returns:
660            IntervalTier: the modified version of the current tier
661        """
662        retEntryList = []
663        for interval in self.entries:
664            subTier = tier.crop(
665                interval.start, interval.end, CropCollision.TRUNCATED, False
666            )
667            if len(subTier._entries) == 0:
668                continue
669
670            subLabel = demarcator.join([entry.label for entry in subTier.entries])
671            label = f"{interval.label}({subLabel})"
672
673            start = min(interval.start, subTier._entries[0].start)
674            end = max(interval.end, subTier._entries[-1].end)
675
676            intersectedInterval = (
677                start,
678                end,
679                label,
680            )
681
682            retEntryList.append(intersectedInterval)
683
684        newName = f"{self.name}-{tier.name}"
685
686        retTier = self.new(newName, retEntryList)
687
688        return retTier
689
690    def morph(
691        self,
692        targetTier: "IntervalTier",
693        filterFunc: Optional[Callable[[str], bool]] = None,
694    ) -> "IntervalTier":
695        """Morphs the duration of segments in this tier to those in another
696
697        This preserves the labels and the duration of silence in
698        this tier while changing the duration of labeled segments.
699
700        Args:
701            targetTier:
702            filterFunc: if specified, filters entries. The
703                functor takes one argument, an Interval. It returns true
704                if the Interval should be modified and false if not.
705
706        Returns:
707            The modified version of the current tier
708        """
709        cumulativeAdjustAmount = 0
710        newEntryList = []
711        allIntervals = [self.entries, targetTier.entries]
712        for sourceInterval, targetInterval in utils.safeZip(allIntervals, True):
713            # sourceInterval.start - lastFromEnd -> was this interval and the
714            # last one adjacent?
715            newStart = sourceInterval.start + cumulativeAdjustAmount
716
717            currIntervalDuration = sourceInterval.end - sourceInterval.start
718            if filterFunc is None or filterFunc(sourceInterval.label):
719                newIntervalDuration = targetInterval.end - targetInterval.start
720                cumulativeAdjustAmount += newIntervalDuration - currIntervalDuration
721                newEnd = newStart + newIntervalDuration
722            else:
723                newEnd = newStart + currIntervalDuration
724
725            newEntryList.append(Interval(newStart, newEnd, sourceInterval.label))
726
727        newMin = self.minTimestamp
728        cumulativeDifference = newEntryList[-1].end - self.entries[-1].end
729        newMax = self.maxTimestamp + cumulativeDifference
730
731        return IntervalTier(self.name, newEntryList, newMin, newMax)
732
733    def validate(
734        self, reportingMode: Literal["silence", "warning", "error"] = "warning"
735    ) -> bool:
736        """Validate this tier
737
738        Args:
739            reportingMode (str): Determines the behavior if validation fails.
740
741        Returns:
742            True if the tier is valid; False if not
743        """
744        utils.validateOption(
745            "reportingMode", reportingMode, constants.ErrorReportingMode
746        )
747        errorReporter = utils.getErrorReporter(reportingMode)
748
749        isValid = True
750        previousInterval = None
751        for interval in self.entries:
752            if interval.start >= interval.end:
753                isValid = False
754                errorReporter(
755                    errors.TextgridStateError,
756                    f"Invalid interval. End time occurs before or on the start time({interval}).",
757                )
758
759            if previousInterval and previousInterval.end > interval.start:
760                isValid = False
761                errorReporter(
762                    errors.TextgridStateError,
763                    f"Intervals are not sorted in time: "
764                    f"[({previousInterval}), ({interval})]",
765                )
766
767            if utils.checkIsUndershoot(
768                interval.start, self.minTimestamp, errorReporter
769            ):
770                isValid = False
771
772            if utils.checkIsOvershoot(interval.end, self.maxTimestamp, errorReporter):
773                isValid = False
774
775            previousInterval = interval
776
777        return isValid
class IntervalTier(praatio.data_classes.textgrid_tier.TextgridTier):
 58class IntervalTier(textgrid_tier.TextgridTier):
 59    tierType = INTERVAL_TIER
 60    entryType = Interval
 61
 62    def __init__(
 63        self,
 64        name: str,
 65        entries: List[Interval],
 66        minT: Optional[float] = None,
 67        maxT: Optional[float] = None,
 68    ):
 69        """An interval tier is for annotating events that have duration
 70
 71        The entries is of the form:
 72        [(startTime1, endTime1, label1), (startTime2, endTime2, label2), ]
 73
 74        The data stored in the labels can be anything but will
 75        be interpreted as text by praatio (the label could be descriptive
 76        text e.g. ('erase this region') or numerical data e.g. (average pitch
 77        values like '132'))
 78        """
 79        entries = _homogenizeEntries(entries)
 80        calculatedMinT, calculatedMaxT = _calculateMinAndMaxTime(entries, minT, maxT)
 81
 82        super(IntervalTier, self).__init__(
 83            name, entries, calculatedMinT, calculatedMaxT
 84        )
 85        self._validate()
 86
 87    def _validate(self):
 88        """An interval tier is invalid if the entries are out of order or overlap with each other"""
 89        for entry in self.entries:
 90            if entry.start >= entry.end:
 91                raise errors.TextgridStateError(
 92                    f"The start time of an interval ({entry.start}) "
 93                    f"cannot occur after its end time ({entry.end})"
 94                )
 95
 96        for entry, nextEntry in zip(self.entries[0::], self.entries[1::]):
 97            if entry.end > nextEntry.start:
 98                raise errors.TextgridStateError(
 99                    "Two intervals in the same tier overlap in time:\n"
100                    f"({entry.start}, {entry.end}, {entry.label}) and "
101                    f"({nextEntry.start}, {nextEntry.end}, {nextEntry.label})"
102                )
103
104    @property
105    def timestamps(self) -> List[float]:
106        """All unique timestamps used in this tier"""
107        tmpTimestamps = [
108            time
109            for start, stop, _ in self.entries
110            for time in [
111                start,
112                stop,
113            ]
114        ]
115
116        uniqueTimestamps = list(set(tmpTimestamps))
117        uniqueTimestamps.sort()
118
119        return uniqueTimestamps
120
121    def crop(
122        self,
123        cropStart: float,
124        cropEnd: float,
125        mode: Literal["strict", "lax", "truncated"],
126        rebaseToZero: bool,
127    ) -> "IntervalTier":
128        """Creates a new tier with all entries that fit inside the new interval
129
130        Args:
131            cropStart:
132            cropEnd:
133            mode: determines cropping behavior
134                - 'strict', only intervals wholly contained by the crop
135                    interval will be kept
136                - 'lax', partially contained intervals will be kept
137                - 'truncated', partially contained intervals will be
138                    truncated to fit within the crop region.
139            rebaseToZero: if True, the cropped textgrid values
140                will be subtracted by the cropStart
141
142        Returns:
143            the modified version of the current tier
144        """
145
146        utils.validateOption("mode", mode, CropCollision)
147
148        if cropStart >= cropEnd:
149            raise errors.ArgumentError(
150                f"Crop error: start time ({cropStart}) must occur before end time ({cropEnd})"
151            )
152
153        newEntryList = utils.getIntervalsInInterval(
154            cropStart, cropEnd, self.entries, mode
155        )
156
157        if rebaseToZero is True:
158            newSmallestValue = newEntryList[0][0]
159            if newSmallestValue < cropStart:
160                timeDiff = newSmallestValue
161            else:
162                timeDiff = cropStart
163            newEntryList = [
164                Interval(start - timeDiff, end - timeDiff, label)
165                for start, end, label in newEntryList
166            ]
167            minT = 0.0
168            maxT = cropEnd - cropStart
169        else:
170            minT = cropStart
171            maxT = cropEnd
172
173        croppedTier = IntervalTier(self.name, newEntryList, minT, maxT)
174
175        return croppedTier
176
177    def dejitter(
178        self,
179        referenceTier: textgrid_tier.TextgridTier,
180        maxDifference: float = 0.001,
181    ) -> textgrid_tier.TextgridTier:
182        """
183        Set timestamps in this tier to be the same as values in the reference tier
184
185        Timestamps will only be moved if they are less than maxDifference away from the
186        reference time.
187
188        This can be used to correct minor alignment errors between tiers, as made when
189        annotating files manually, etc.
190
191        Args:
192            referenceTier: the IntervalTier or PointTier to use as a reference
193            maxDifference: the maximum amount to allow timestamps to be moved by
194
195        Returns:
196            the modified version of the current tier
197        """
198        referenceTimestamps = referenceTier.timestamps
199
200        newEntries = []
201        for start, stop, label in self.entries:
202            startCompare = min(referenceTimestamps, key=lambda x: abs(x - start))
203            stopCompare = min(referenceTimestamps, key=lambda x: abs(x - stop))
204
205            if my_math.lessThanOrEqual(abs(start - startCompare), maxDifference):
206                start = startCompare
207            if my_math.lessThanOrEqual(abs(stop - stopCompare), maxDifference):
208                stop = stopCompare
209            newEntries.append((start, stop, label))
210
211        return self.new(entries=newEntries)
212
213    def deleteEntry(self, entry: Interval) -> None:
214        """Removes an entry from the entries"""
215        self._entries.pop(self._entries.index(entry))
216
217    def difference(self, tier: "IntervalTier") -> "IntervalTier":
218        """Takes the set difference of this tier and the given one
219
220        Any overlapping portions of entries with entries in this textgrid
221        will be removed from the returned tier.
222
223        Args:
224            tier: the tier to subtract from this one
225
226        Returns:
227            the modified version of the current tier
228        """
229        retTier = self.new()
230
231        for entry in tier.entries:
232            retTier = retTier.eraseRegion(
233                entry.start,
234                entry.end,
235                collisionMode=constants.EraseCollision.TRUNCATE,
236                doShrink=False,
237            )
238
239        return retTier
240
241    def editTimestamps(
242        self,
243        offset: float,
244        reportingMode: Literal["silence", "warning", "error"] = "warning",
245    ) -> "IntervalTier":
246        """Modifies all timestamps by a constant amount
247
248        Args:
249            offset: the amount to shift all intervals
250            reportingMode: Determines the behavior if an entries moves outside
251                of minTimestamp or maxTimestamp after being edited
252
253        Returns:
254            the modified version of the current tier
255        """
256        utils.validateOption(
257            "reportingMode", reportingMode, constants.ErrorReportingMode
258        )
259        errorReporter = utils.getErrorReporter(reportingMode)
260
261        newEntryList = []
262        for interval in self.entries:
263            newStart = offset + interval.start
264            newEnd = offset + interval.end
265
266            utils.checkIsUndershoot(newStart, self.minTimestamp, errorReporter)
267            utils.checkIsOvershoot(newEnd, self.maxTimestamp, errorReporter)
268
269            if newEnd <= 0:
270                continue
271            if newStart < 0:
272                newStart = 0
273
274            newEntryList.append(Interval(newStart, newEnd, interval.label))
275
276        # Determine new min and max timestamps
277        newMin = min([interval.start for interval in newEntryList])
278        newMax = max([interval.end for interval in newEntryList])
279
280        if newMin > self.minTimestamp:
281            newMin = self.minTimestamp
282
283        if newMax < self.maxTimestamp:
284            newMax = self.maxTimestamp
285
286        return IntervalTier(self.name, newEntryList, newMin, newMax)
287
288    def eraseRegion(
289        self,
290        start: float,
291        end: float,
292        collisionMode: Literal["truncate", "categorical", "error"] = "error",
293        doShrink: bool = True,
294    ) -> "IntervalTier":
295        """Makes a region in a tier blank (removes all contained entries)
296
297        Args:
298            start:
299            end:
300            collisionMode: Determines the behavior when the region to erase
301                overlaps with existing intervals.
302                - 'truncate' partially contained entries will have the portion
303                    removed that overlaps with the target entry
304                - 'categorical' all entries that overlap, even partially, with
305                    the target entry will be completely removed
306                - None or any other value throws IntervalCollision
307            doShrink: If True, moves leftward by (/end/ - /start/)
308                amount, each item that occurs after /end/
309
310        Returns:
311            The modified version of the current tier
312
313        Raises:
314            CollisionError
315        """
316        utils.validateOption("collisionMode", collisionMode, constants.EraseCollision)
317
318        matchList = self.crop(start, end, CropCollision.LAX, False).entries
319        newTier = self.new()
320
321        if len(matchList) == 0:
322            pass
323        else:
324            if collisionMode == constants.EraseCollision.ERROR:
325                raise errors.CollisionError(
326                    f"Erase region ({start}, {end})overlapped with an interval. "
327                    "If this was expected, consider setting the collisionMode"
328                )
329
330            # Remove all the matches from the entries
331            # Go in reverse order because we're destructively altering
332            # the order of the list (messes up index order)
333            for interval in matchList[::-1]:
334                newTier.deleteEntry(interval)
335
336            # If we're only truncating, reinsert entries on the left and
337            # right edges
338            # if categorical, it doesn't make it into the list at all
339            if collisionMode == constants.EraseCollision.TRUNCATE:
340                # Check left edge
341                if matchList[0].start < start:
342                    newEntry = Interval(matchList[0].start, start, matchList[0].label)
343                    newTier.insertEntry(newEntry)
344
345                # Check right edge
346                if matchList[-1].end > end:
347                    newEntry = Interval(end, matchList[-1].end, matchList[-1].label)
348                    newTier.insertEntry(newEntry)
349
350        if doShrink is True:
351            diff = end - start
352            newEntryList = []
353            for interval in newTier.entries:
354                if interval.end <= start:
355                    newEntryList.append(interval)
356                elif interval.start >= end:
357                    newEntryList.append(
358                        Interval(
359                            interval.start - diff, interval.end - diff, interval.label
360                        )
361                    )
362
363            # Special case: an interval that spanned the deleted
364            # section
365            for i in range(0, len(newEntryList) - 1):
366                rightEdge = newEntryList[i].end == start
367                leftEdge = newEntryList[i + 1].start == start
368                sameLabel = newEntryList[i].label == newEntryList[i + 1].label
369                if rightEdge and leftEdge and sameLabel:
370                    newInterval = Interval(
371                        newEntryList[i].start,
372                        newEntryList[i + 1].end,
373                        newEntryList[i].label,
374                    )
375
376                    newEntryList.pop(i + 1)
377                    newEntryList.pop(i)
378                    newEntryList.insert(i, newInterval)
379
380                    # Only one interval can span the deleted section,
381                    # so if we've found it, move on
382                    break
383
384            newMax = newTier.maxTimestamp - diff
385            newTier = newTier.new(entries=newEntryList, maxTimestamp=newMax)
386
387        return newTier
388
389    def getValuesInIntervals(self, dataTupleList: List) -> List[Tuple[Interval, List]]:
390        """Returns data from dataTupleList contained in labeled intervals
391
392        Each labeled interval will get its own list of data values.
393
394        dataTupleList should be of the form:
395        [(time1, value1a, value1b,...), (time2, value2a, value2b...), ...]
396        """
397
398        returnList = []
399
400        for interval in self.entries:
401            intervalDataList = utils.getValuesInInterval(
402                dataTupleList, interval.start, interval.end
403            )
404            returnList.append((interval, intervalDataList))
405
406        return returnList
407
408    def getNonEntries(self) -> List[Interval]:
409        """Returns the regions of the textgrid without labels
410
411        This can include unlabeled segments and regions marked as silent.
412        """
413        entries = self.entries
414        invertedEntryList = [
415            Interval(entries[i].end, entries[i + 1].start, "")
416            for i in range(len(entries) - 1)
417        ]
418
419        # Remove entries that have no duration (ie lie between two entries
420        # that share a border)
421        invertedEntryList = [
422            interval for interval in invertedEntryList if interval.start < interval.end
423        ]
424
425        if entries[0].start > 0:
426            invertedEntryList.insert(0, Interval(0, entries[0].start, ""))
427
428        if entries[-1].end < self.maxTimestamp:
429            invertedEntryList.append(Interval(entries[-1].end, self.maxTimestamp, ""))
430
431        invertedEntryList = [
432            interval if isinstance(interval, Interval) else Interval(*interval)
433            for interval in invertedEntryList
434        ]
435
436        return invertedEntryList
437
438    def insertEntry(
439        self,
440        entry: Interval,
441        collisionMode: Literal["replace", "merge", "error"] = "error",
442        collisionReportingMode: Literal["silence", "warning"] = "warning",
443    ) -> None:
444        """Inserts an interval into the tier
445
446        Args:
447            entry: the Interval to insert
448            collisionMode: determines the behavior in the event that intervals
449                exist in the insertion area.
450                - 'replace' will remove existing items
451                - 'merge' will fuse the inserting item with existing items
452                - None or any other value will throw a CollisionError
453            collisionReportingMode: Determines the behavior if the new entry
454                overlaps with an existing one
455
456        Returns:
457            the modified version of the current tier
458        """
459        utils.validateOption(
460            "collisionMode", collisionMode, constants.IntervalCollision
461        )
462        utils.validateOption(
463            "collisionReportingMode",
464            collisionReportingMode,
465            constants.ErrorReportingMode,
466        )
467        collisionReporter = utils.getErrorReporter(collisionReportingMode)
468
469        if not isinstance(entry, Interval):
470            interval = Interval(*entry)
471        else:
472            interval = entry
473
474        matchList = self.crop(
475            interval.start, interval.end, CropCollision.LAX, False
476        )._entries
477
478        if len(matchList) == 0:
479            self._entries.append(interval)
480
481        elif collisionMode == constants.IntervalCollision.REPLACE:
482            for matchEntry in matchList:
483                self.deleteEntry(matchEntry)
484            self._entries.append(interval)
485
486        elif collisionMode == constants.IntervalCollision.MERGE:
487            for matchEntry in matchList:
488                self.deleteEntry(matchEntry)
489            matchList.append(interval)
490            matchList.sort()  # By starting time
491
492            newInterval = Interval(
493                min([tmpInterval.start for tmpInterval in matchList]),
494                max([tmpInterval.end for tmpInterval in matchList]),
495                "-".join([tmpInterval.label for tmpInterval in matchList]),
496            )
497            self._entries.append(newInterval)
498
499        else:
500            raise errors.CollisionError(
501                "Attempted to insert interval "
502                f"({interval.start}, {interval.end}, '{interval.label}') into tier {self.name} "
503                "of textgrid but overlapping entries "
504                f"{[tuple(interval) for interval in matchList]} "
505                "already exist"
506            )
507
508        self.sort()
509
510        if self._entries[0][0] < self.minTimestamp:
511            self.minTimestamp = self._entries[0][0]
512
513        if self._entries[-1][1] > self.maxTimestamp:
514            self.maxTimestamp = self._entries[-1][1]
515
516        if len(matchList) != 0:
517            collisionReporter(
518                errors.CollisionError,
519                f"Collision warning for ({interval}) with items "
520                f"({matchList}) of tier '{self.name}'",
521            )
522
523    def insertSpace(
524        self,
525        start: float,
526        duration: float,
527        collisionMode: Literal["stretch", "split", "no_change", "error"],
528    ) -> "IntervalTier":
529        """Inserts a blank region into the tier
530
531        Args:
532            start:
533            duration:
534            collisionMode: Determines the behavior that occurs if
535                an interval stradles the starting point
536                - 'stretch' stretches the interval by /duration/ amount
537                - 'split' splits the interval into two--everything to the
538                    right of 'start' will be advanced by 'duration' seconds
539                - 'no change' leaves the interval as is with no change
540                - 'error' will stop execution and raise an error
541
542        Returns:
543            the modified version of the current tier
544        """
545        utils.validateOption(
546            "collisionMode", collisionMode, constants.WhitespaceCollision
547        )
548
549        newEntryList = []
550        for interval in self.entries:
551            # Entry exists before the insertion point
552            if interval.end <= start:
553                newEntryList.append(interval)
554            # Entry exists after the insertion point
555            elif interval.start >= start:
556                newEntryList.append(
557                    Interval(
558                        interval.start + duration,
559                        interval.end + duration,
560                        interval.label,
561                    )
562                )
563            # Entry straddles the insertion point
564            elif interval.start <= start and interval.end > start:
565                if collisionMode == constants.WhitespaceCollision.STRETCH:
566                    newEntryList.append(
567                        Interval(
568                            interval.start, interval.end + duration, interval.label
569                        )
570                    )
571                elif collisionMode == constants.WhitespaceCollision.SPLIT:
572                    # Left side of the split
573                    newEntryList.append(Interval(interval.start, start, interval.label))
574                    # Right side of the split
575                    newEntryList.append(
576                        (
577                            start + duration,
578                            start + duration + (interval.end - start),
579                            interval.label,
580                        )
581                    )
582                elif collisionMode == constants.WhitespaceCollision.NO_CHANGE:
583                    newEntryList.append(interval)
584                else:
585                    raise errors.ArgumentError(
586                        f"Collision occured during insertSpace() for interval '{interval}' "
587                        f"and given white space insertion interval ({start}, {start + duration})"
588                    )
589
590        newTier = self.new(
591            entries=newEntryList, maxTimestamp=self.maxTimestamp + duration
592        )
593
594        return newTier
595
596    def intersection(self, tier: "IntervalTier", demarcator="-") -> "IntervalTier":
597        """Takes the set intersection of this tier and the given one
598
599        - The output will contain one interval for each overlapping pair
600          e.g. [(1, 2, 'foo')] and [(1, 1.3, 'bang'), (1.7, 2, 'wizz')]
601                -> [(1, 1.3, 'foo-bang'), (1.7, 2, 'foo-wizz')]
602        - Only intervals that exist in both tiers will remain in the returned tier.
603          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
604                -> [(1, 2, 'foo-bang')]
605        - If intervals partially overlap, only the overlapping portion will be returned.
606          e.g. [(1, 2, 'foo')] and [(0.5, 1.5, 'bang')]
607                -> [(1, 1.5, 'foo-bang')]
608
609        Compare with IntervalTier.mergeLabels
610
611        Args:
612            tier: the tier to intersect with
613            demarcator: the character to separate the labels of the overlapping intervals
614
615        Returns:
616            IntervalTier: the modified version of the current tier
617        """
618        retEntryList = []
619        for interval in tier.entries:
620            subTier = self.crop(
621                interval.start, interval.end, CropCollision.TRUNCATED, False
622            )
623
624            # Combine the labels in the two tiers
625            subEntryList = [
626                (
627                    subInterval.start,
628                    subInterval.end,
629                    f"{subInterval.label}{demarcator}{interval.label}",
630                )
631                for subInterval in subTier.entries
632            ]
633
634            retEntryList.extend(subEntryList)
635
636        newName = f"{self.name}-{tier.name}"
637
638        retTier = self.new(newName, retEntryList)
639
640        return retTier
641
642    def mergeLabels(
643        self, tier: "IntervalTier", demarcator: str = ","
644    ) -> "IntervalTier":
645        """Merges labels of overlapping tiers into this tier
646
647        - All intervals in this tier will appear in the output; for the given tier, only intervals
648          that overlap with content in this tier will appear in the output
649          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
650                -> [(1, 2, 'foo(bang)'), (3, 4, 'bar()')]
651        - If multiple entries exist in a subinterval, their labels will be concatenated
652          e.g. [(1, 2, 'hi')] and [(1, 1.5, 'h'), (1.5, 2, 'ai')] -> [(1, 2, 'hi(h,ai)')]
653
654        compare with IntervalTier.intersection
655
656        Args:
657            tier: the tier to intersect with
658            demarcator: the string to separate items that fall in the same subinterval
659
660        Returns:
661            IntervalTier: the modified version of the current tier
662        """
663        retEntryList = []
664        for interval in self.entries:
665            subTier = tier.crop(
666                interval.start, interval.end, CropCollision.TRUNCATED, False
667            )
668            if len(subTier._entries) == 0:
669                continue
670
671            subLabel = demarcator.join([entry.label for entry in subTier.entries])
672            label = f"{interval.label}({subLabel})"
673
674            start = min(interval.start, subTier._entries[0].start)
675            end = max(interval.end, subTier._entries[-1].end)
676
677            intersectedInterval = (
678                start,
679                end,
680                label,
681            )
682
683            retEntryList.append(intersectedInterval)
684
685        newName = f"{self.name}-{tier.name}"
686
687        retTier = self.new(newName, retEntryList)
688
689        return retTier
690
691    def morph(
692        self,
693        targetTier: "IntervalTier",
694        filterFunc: Optional[Callable[[str], bool]] = None,
695    ) -> "IntervalTier":
696        """Morphs the duration of segments in this tier to those in another
697
698        This preserves the labels and the duration of silence in
699        this tier while changing the duration of labeled segments.
700
701        Args:
702            targetTier:
703            filterFunc: if specified, filters entries. The
704                functor takes one argument, an Interval. It returns true
705                if the Interval should be modified and false if not.
706
707        Returns:
708            The modified version of the current tier
709        """
710        cumulativeAdjustAmount = 0
711        newEntryList = []
712        allIntervals = [self.entries, targetTier.entries]
713        for sourceInterval, targetInterval in utils.safeZip(allIntervals, True):
714            # sourceInterval.start - lastFromEnd -> was this interval and the
715            # last one adjacent?
716            newStart = sourceInterval.start + cumulativeAdjustAmount
717
718            currIntervalDuration = sourceInterval.end - sourceInterval.start
719            if filterFunc is None or filterFunc(sourceInterval.label):
720                newIntervalDuration = targetInterval.end - targetInterval.start
721                cumulativeAdjustAmount += newIntervalDuration - currIntervalDuration
722                newEnd = newStart + newIntervalDuration
723            else:
724                newEnd = newStart + currIntervalDuration
725
726            newEntryList.append(Interval(newStart, newEnd, sourceInterval.label))
727
728        newMin = self.minTimestamp
729        cumulativeDifference = newEntryList[-1].end - self.entries[-1].end
730        newMax = self.maxTimestamp + cumulativeDifference
731
732        return IntervalTier(self.name, newEntryList, newMin, newMax)
733
734    def validate(
735        self, reportingMode: Literal["silence", "warning", "error"] = "warning"
736    ) -> bool:
737        """Validate this tier
738
739        Args:
740            reportingMode (str): Determines the behavior if validation fails.
741
742        Returns:
743            True if the tier is valid; False if not
744        """
745        utils.validateOption(
746            "reportingMode", reportingMode, constants.ErrorReportingMode
747        )
748        errorReporter = utils.getErrorReporter(reportingMode)
749
750        isValid = True
751        previousInterval = None
752        for interval in self.entries:
753            if interval.start >= interval.end:
754                isValid = False
755                errorReporter(
756                    errors.TextgridStateError,
757                    f"Invalid interval. End time occurs before or on the start time({interval}).",
758                )
759
760            if previousInterval and previousInterval.end > interval.start:
761                isValid = False
762                errorReporter(
763                    errors.TextgridStateError,
764                    f"Intervals are not sorted in time: "
765                    f"[({previousInterval}), ({interval})]",
766                )
767
768            if utils.checkIsUndershoot(
769                interval.start, self.minTimestamp, errorReporter
770            ):
771                isValid = False
772
773            if utils.checkIsOvershoot(interval.end, self.maxTimestamp, errorReporter):
774                isValid = False
775
776            previousInterval = interval
777
778        return isValid

Helper class that provides a standard way to create an ABC using inheritance.

IntervalTier( name: str, entries: List[praatio.utilities.constants.Interval], minT: Optional[float] = None, maxT: Optional[float] = None)
62    def __init__(
63        self,
64        name: str,
65        entries: List[Interval],
66        minT: Optional[float] = None,
67        maxT: Optional[float] = None,
68    ):
69        """An interval tier is for annotating events that have duration
70
71        The entries is of the form:
72        [(startTime1, endTime1, label1), (startTime2, endTime2, label2), ]
73
74        The data stored in the labels can be anything but will
75        be interpreted as text by praatio (the label could be descriptive
76        text e.g. ('erase this region') or numerical data e.g. (average pitch
77        values like '132'))
78        """
79        entries = _homogenizeEntries(entries)
80        calculatedMinT, calculatedMaxT = _calculateMinAndMaxTime(entries, minT, maxT)
81
82        super(IntervalTier, self).__init__(
83            name, entries, calculatedMinT, calculatedMaxT
84        )
85        self._validate()

An interval tier is for annotating events that have duration

The entries is of the form: [(startTime1, endTime1, label1), (startTime2, endTime2, label2), ]

The data stored in the labels can be anything but will be interpreted as text by praatio (the label could be descriptive text e.g. ('erase this region') or numerical data e.g. (average pitch values like '132'))

tierType = 'IntervalTier'
entryType = <class 'praatio.utilities.constants.Interval'>
timestamps: List[float]

All unique timestamps used in this tier

def crop( self, cropStart: float, cropEnd: float, mode: Literal['strict', 'lax', 'truncated'], rebaseToZero: bool) -> IntervalTier:
121    def crop(
122        self,
123        cropStart: float,
124        cropEnd: float,
125        mode: Literal["strict", "lax", "truncated"],
126        rebaseToZero: bool,
127    ) -> "IntervalTier":
128        """Creates a new tier with all entries that fit inside the new interval
129
130        Args:
131            cropStart:
132            cropEnd:
133            mode: determines cropping behavior
134                - 'strict', only intervals wholly contained by the crop
135                    interval will be kept
136                - 'lax', partially contained intervals will be kept
137                - 'truncated', partially contained intervals will be
138                    truncated to fit within the crop region.
139            rebaseToZero: if True, the cropped textgrid values
140                will be subtracted by the cropStart
141
142        Returns:
143            the modified version of the current tier
144        """
145
146        utils.validateOption("mode", mode, CropCollision)
147
148        if cropStart >= cropEnd:
149            raise errors.ArgumentError(
150                f"Crop error: start time ({cropStart}) must occur before end time ({cropEnd})"
151            )
152
153        newEntryList = utils.getIntervalsInInterval(
154            cropStart, cropEnd, self.entries, mode
155        )
156
157        if rebaseToZero is True:
158            newSmallestValue = newEntryList[0][0]
159            if newSmallestValue < cropStart:
160                timeDiff = newSmallestValue
161            else:
162                timeDiff = cropStart
163            newEntryList = [
164                Interval(start - timeDiff, end - timeDiff, label)
165                for start, end, label in newEntryList
166            ]
167            minT = 0.0
168            maxT = cropEnd - cropStart
169        else:
170            minT = cropStart
171            maxT = cropEnd
172
173        croppedTier = IntervalTier(self.name, newEntryList, minT, maxT)
174
175        return croppedTier

Creates a new tier with all entries that fit inside the new interval

Arguments:
  • cropStart:
  • cropEnd:
  • mode: determines cropping behavior
    • 'strict', only intervals wholly contained by the crop interval will be kept
    • 'lax', partially contained intervals will be kept
    • 'truncated', partially contained intervals will be truncated to fit within the crop region.
  • rebaseToZero: if True, the cropped textgrid values will be subtracted by the cropStart
Returns:

the modified version of the current tier

def dejitter( self, referenceTier: praatio.data_classes.textgrid_tier.TextgridTier, maxDifference: float = 0.001) -> praatio.data_classes.textgrid_tier.TextgridTier:
177    def dejitter(
178        self,
179        referenceTier: textgrid_tier.TextgridTier,
180        maxDifference: float = 0.001,
181    ) -> textgrid_tier.TextgridTier:
182        """
183        Set timestamps in this tier to be the same as values in the reference tier
184
185        Timestamps will only be moved if they are less than maxDifference away from the
186        reference time.
187
188        This can be used to correct minor alignment errors between tiers, as made when
189        annotating files manually, etc.
190
191        Args:
192            referenceTier: the IntervalTier or PointTier to use as a reference
193            maxDifference: the maximum amount to allow timestamps to be moved by
194
195        Returns:
196            the modified version of the current tier
197        """
198        referenceTimestamps = referenceTier.timestamps
199
200        newEntries = []
201        for start, stop, label in self.entries:
202            startCompare = min(referenceTimestamps, key=lambda x: abs(x - start))
203            stopCompare = min(referenceTimestamps, key=lambda x: abs(x - stop))
204
205            if my_math.lessThanOrEqual(abs(start - startCompare), maxDifference):
206                start = startCompare
207            if my_math.lessThanOrEqual(abs(stop - stopCompare), maxDifference):
208                stop = stopCompare
209            newEntries.append((start, stop, label))
210
211        return self.new(entries=newEntries)

Set timestamps in this tier to be the same as values in the reference tier

Timestamps will only be moved if they are less than maxDifference away from the reference time.

This can be used to correct minor alignment errors between tiers, as made when annotating files manually, etc.

Arguments:
  • referenceTier: the IntervalTier or PointTier to use as a reference
  • maxDifference: the maximum amount to allow timestamps to be moved by
Returns:

the modified version of the current tier

def deleteEntry(self, entry: praatio.utilities.constants.Interval) -> None:
213    def deleteEntry(self, entry: Interval) -> None:
214        """Removes an entry from the entries"""
215        self._entries.pop(self._entries.index(entry))

Removes an entry from the entries

def difference( self, tier: IntervalTier) -> IntervalTier:
217    def difference(self, tier: "IntervalTier") -> "IntervalTier":
218        """Takes the set difference of this tier and the given one
219
220        Any overlapping portions of entries with entries in this textgrid
221        will be removed from the returned tier.
222
223        Args:
224            tier: the tier to subtract from this one
225
226        Returns:
227            the modified version of the current tier
228        """
229        retTier = self.new()
230
231        for entry in tier.entries:
232            retTier = retTier.eraseRegion(
233                entry.start,
234                entry.end,
235                collisionMode=constants.EraseCollision.TRUNCATE,
236                doShrink=False,
237            )
238
239        return retTier

Takes the set difference of this tier and the given one

Any overlapping portions of entries with entries in this textgrid will be removed from the returned tier.

Arguments:
  • tier: the tier to subtract from this one
Returns:

the modified version of the current tier

def editTimestamps( self, offset: float, reportingMode: Literal['silence', 'warning', 'error'] = 'warning') -> IntervalTier:
241    def editTimestamps(
242        self,
243        offset: float,
244        reportingMode: Literal["silence", "warning", "error"] = "warning",
245    ) -> "IntervalTier":
246        """Modifies all timestamps by a constant amount
247
248        Args:
249            offset: the amount to shift all intervals
250            reportingMode: Determines the behavior if an entries moves outside
251                of minTimestamp or maxTimestamp after being edited
252
253        Returns:
254            the modified version of the current tier
255        """
256        utils.validateOption(
257            "reportingMode", reportingMode, constants.ErrorReportingMode
258        )
259        errorReporter = utils.getErrorReporter(reportingMode)
260
261        newEntryList = []
262        for interval in self.entries:
263            newStart = offset + interval.start
264            newEnd = offset + interval.end
265
266            utils.checkIsUndershoot(newStart, self.minTimestamp, errorReporter)
267            utils.checkIsOvershoot(newEnd, self.maxTimestamp, errorReporter)
268
269            if newEnd <= 0:
270                continue
271            if newStart < 0:
272                newStart = 0
273
274            newEntryList.append(Interval(newStart, newEnd, interval.label))
275
276        # Determine new min and max timestamps
277        newMin = min([interval.start for interval in newEntryList])
278        newMax = max([interval.end for interval in newEntryList])
279
280        if newMin > self.minTimestamp:
281            newMin = self.minTimestamp
282
283        if newMax < self.maxTimestamp:
284            newMax = self.maxTimestamp
285
286        return IntervalTier(self.name, newEntryList, newMin, newMax)

Modifies all timestamps by a constant amount

Arguments:
  • offset: the amount to shift all intervals
  • reportingMode: Determines the behavior if an entries moves outside of minTimestamp or maxTimestamp after being edited
Returns:

the modified version of the current tier

def eraseRegion( self, start: float, end: float, collisionMode: Literal['truncate', 'categorical', 'error'] = 'error', doShrink: bool = True) -> IntervalTier:
288    def eraseRegion(
289        self,
290        start: float,
291        end: float,
292        collisionMode: Literal["truncate", "categorical", "error"] = "error",
293        doShrink: bool = True,
294    ) -> "IntervalTier":
295        """Makes a region in a tier blank (removes all contained entries)
296
297        Args:
298            start:
299            end:
300            collisionMode: Determines the behavior when the region to erase
301                overlaps with existing intervals.
302                - 'truncate' partially contained entries will have the portion
303                    removed that overlaps with the target entry
304                - 'categorical' all entries that overlap, even partially, with
305                    the target entry will be completely removed
306                - None or any other value throws IntervalCollision
307            doShrink: If True, moves leftward by (/end/ - /start/)
308                amount, each item that occurs after /end/
309
310        Returns:
311            The modified version of the current tier
312
313        Raises:
314            CollisionError
315        """
316        utils.validateOption("collisionMode", collisionMode, constants.EraseCollision)
317
318        matchList = self.crop(start, end, CropCollision.LAX, False).entries
319        newTier = self.new()
320
321        if len(matchList) == 0:
322            pass
323        else:
324            if collisionMode == constants.EraseCollision.ERROR:
325                raise errors.CollisionError(
326                    f"Erase region ({start}, {end})overlapped with an interval. "
327                    "If this was expected, consider setting the collisionMode"
328                )
329
330            # Remove all the matches from the entries
331            # Go in reverse order because we're destructively altering
332            # the order of the list (messes up index order)
333            for interval in matchList[::-1]:
334                newTier.deleteEntry(interval)
335
336            # If we're only truncating, reinsert entries on the left and
337            # right edges
338            # if categorical, it doesn't make it into the list at all
339            if collisionMode == constants.EraseCollision.TRUNCATE:
340                # Check left edge
341                if matchList[0].start < start:
342                    newEntry = Interval(matchList[0].start, start, matchList[0].label)
343                    newTier.insertEntry(newEntry)
344
345                # Check right edge
346                if matchList[-1].end > end:
347                    newEntry = Interval(end, matchList[-1].end, matchList[-1].label)
348                    newTier.insertEntry(newEntry)
349
350        if doShrink is True:
351            diff = end - start
352            newEntryList = []
353            for interval in newTier.entries:
354                if interval.end <= start:
355                    newEntryList.append(interval)
356                elif interval.start >= end:
357                    newEntryList.append(
358                        Interval(
359                            interval.start - diff, interval.end - diff, interval.label
360                        )
361                    )
362
363            # Special case: an interval that spanned the deleted
364            # section
365            for i in range(0, len(newEntryList) - 1):
366                rightEdge = newEntryList[i].end == start
367                leftEdge = newEntryList[i + 1].start == start
368                sameLabel = newEntryList[i].label == newEntryList[i + 1].label
369                if rightEdge and leftEdge and sameLabel:
370                    newInterval = Interval(
371                        newEntryList[i].start,
372                        newEntryList[i + 1].end,
373                        newEntryList[i].label,
374                    )
375
376                    newEntryList.pop(i + 1)
377                    newEntryList.pop(i)
378                    newEntryList.insert(i, newInterval)
379
380                    # Only one interval can span the deleted section,
381                    # so if we've found it, move on
382                    break
383
384            newMax = newTier.maxTimestamp - diff
385            newTier = newTier.new(entries=newEntryList, maxTimestamp=newMax)
386
387        return newTier

Makes a region in a tier blank (removes all contained entries)

Arguments:
  • start:
  • end:
  • collisionMode: Determines the behavior when the region to erase overlaps with existing intervals.
    • 'truncate' partially contained entries will have the portion removed that overlaps with the target entry
    • 'categorical' all entries that overlap, even partially, with the target entry will be completely removed
    • None or any other value throws IntervalCollision
  • doShrink: If True, moves leftward by (/end/ - /start/) amount, each item that occurs after /end/
Returns:

The modified version of the current tier

Raises:
  • CollisionError
def getValuesInIntervals( self, dataTupleList: List) -> List[Tuple[praatio.utilities.constants.Interval, List]]:
389    def getValuesInIntervals(self, dataTupleList: List) -> List[Tuple[Interval, List]]:
390        """Returns data from dataTupleList contained in labeled intervals
391
392        Each labeled interval will get its own list of data values.
393
394        dataTupleList should be of the form:
395        [(time1, value1a, value1b,...), (time2, value2a, value2b...), ...]
396        """
397
398        returnList = []
399
400        for interval in self.entries:
401            intervalDataList = utils.getValuesInInterval(
402                dataTupleList, interval.start, interval.end
403            )
404            returnList.append((interval, intervalDataList))
405
406        return returnList

Returns data from dataTupleList contained in labeled intervals

Each labeled interval will get its own list of data values.

dataTupleList should be of the form: [(time1, value1a, value1b,...), (time2, value2a, value2b...), ...]

def getNonEntries(self) -> List[praatio.utilities.constants.Interval]:
408    def getNonEntries(self) -> List[Interval]:
409        """Returns the regions of the textgrid without labels
410
411        This can include unlabeled segments and regions marked as silent.
412        """
413        entries = self.entries
414        invertedEntryList = [
415            Interval(entries[i].end, entries[i + 1].start, "")
416            for i in range(len(entries) - 1)
417        ]
418
419        # Remove entries that have no duration (ie lie between two entries
420        # that share a border)
421        invertedEntryList = [
422            interval for interval in invertedEntryList if interval.start < interval.end
423        ]
424
425        if entries[0].start > 0:
426            invertedEntryList.insert(0, Interval(0, entries[0].start, ""))
427
428        if entries[-1].end < self.maxTimestamp:
429            invertedEntryList.append(Interval(entries[-1].end, self.maxTimestamp, ""))
430
431        invertedEntryList = [
432            interval if isinstance(interval, Interval) else Interval(*interval)
433            for interval in invertedEntryList
434        ]
435
436        return invertedEntryList

Returns the regions of the textgrid without labels

This can include unlabeled segments and regions marked as silent.

def insertEntry( self, entry: praatio.utilities.constants.Interval, collisionMode: Literal['replace', 'merge', 'error'] = 'error', collisionReportingMode: Literal['silence', 'warning'] = 'warning') -> None:
438    def insertEntry(
439        self,
440        entry: Interval,
441        collisionMode: Literal["replace", "merge", "error"] = "error",
442        collisionReportingMode: Literal["silence", "warning"] = "warning",
443    ) -> None:
444        """Inserts an interval into the tier
445
446        Args:
447            entry: the Interval to insert
448            collisionMode: determines the behavior in the event that intervals
449                exist in the insertion area.
450                - 'replace' will remove existing items
451                - 'merge' will fuse the inserting item with existing items
452                - None or any other value will throw a CollisionError
453            collisionReportingMode: Determines the behavior if the new entry
454                overlaps with an existing one
455
456        Returns:
457            the modified version of the current tier
458        """
459        utils.validateOption(
460            "collisionMode", collisionMode, constants.IntervalCollision
461        )
462        utils.validateOption(
463            "collisionReportingMode",
464            collisionReportingMode,
465            constants.ErrorReportingMode,
466        )
467        collisionReporter = utils.getErrorReporter(collisionReportingMode)
468
469        if not isinstance(entry, Interval):
470            interval = Interval(*entry)
471        else:
472            interval = entry
473
474        matchList = self.crop(
475            interval.start, interval.end, CropCollision.LAX, False
476        )._entries
477
478        if len(matchList) == 0:
479            self._entries.append(interval)
480
481        elif collisionMode == constants.IntervalCollision.REPLACE:
482            for matchEntry in matchList:
483                self.deleteEntry(matchEntry)
484            self._entries.append(interval)
485
486        elif collisionMode == constants.IntervalCollision.MERGE:
487            for matchEntry in matchList:
488                self.deleteEntry(matchEntry)
489            matchList.append(interval)
490            matchList.sort()  # By starting time
491
492            newInterval = Interval(
493                min([tmpInterval.start for tmpInterval in matchList]),
494                max([tmpInterval.end for tmpInterval in matchList]),
495                "-".join([tmpInterval.label for tmpInterval in matchList]),
496            )
497            self._entries.append(newInterval)
498
499        else:
500            raise errors.CollisionError(
501                "Attempted to insert interval "
502                f"({interval.start}, {interval.end}, '{interval.label}') into tier {self.name} "
503                "of textgrid but overlapping entries "
504                f"{[tuple(interval) for interval in matchList]} "
505                "already exist"
506            )
507
508        self.sort()
509
510        if self._entries[0][0] < self.minTimestamp:
511            self.minTimestamp = self._entries[0][0]
512
513        if self._entries[-1][1] > self.maxTimestamp:
514            self.maxTimestamp = self._entries[-1][1]
515
516        if len(matchList) != 0:
517            collisionReporter(
518                errors.CollisionError,
519                f"Collision warning for ({interval}) with items "
520                f"({matchList}) of tier '{self.name}'",
521            )

Inserts an interval into the tier

Arguments:
  • entry: the Interval to insert
  • collisionMode: determines the behavior in the event that intervals exist in the insertion area.
    • 'replace' will remove existing items
    • 'merge' will fuse the inserting item with existing items
    • None or any other value will throw a CollisionError
  • collisionReportingMode: Determines the behavior if the new entry overlaps with an existing one
Returns:

the modified version of the current tier

def insertSpace( self, start: float, duration: float, collisionMode: Literal['stretch', 'split', 'no_change', 'error']) -> IntervalTier:
523    def insertSpace(
524        self,
525        start: float,
526        duration: float,
527        collisionMode: Literal["stretch", "split", "no_change", "error"],
528    ) -> "IntervalTier":
529        """Inserts a blank region into the tier
530
531        Args:
532            start:
533            duration:
534            collisionMode: Determines the behavior that occurs if
535                an interval stradles the starting point
536                - 'stretch' stretches the interval by /duration/ amount
537                - 'split' splits the interval into two--everything to the
538                    right of 'start' will be advanced by 'duration' seconds
539                - 'no change' leaves the interval as is with no change
540                - 'error' will stop execution and raise an error
541
542        Returns:
543            the modified version of the current tier
544        """
545        utils.validateOption(
546            "collisionMode", collisionMode, constants.WhitespaceCollision
547        )
548
549        newEntryList = []
550        for interval in self.entries:
551            # Entry exists before the insertion point
552            if interval.end <= start:
553                newEntryList.append(interval)
554            # Entry exists after the insertion point
555            elif interval.start >= start:
556                newEntryList.append(
557                    Interval(
558                        interval.start + duration,
559                        interval.end + duration,
560                        interval.label,
561                    )
562                )
563            # Entry straddles the insertion point
564            elif interval.start <= start and interval.end > start:
565                if collisionMode == constants.WhitespaceCollision.STRETCH:
566                    newEntryList.append(
567                        Interval(
568                            interval.start, interval.end + duration, interval.label
569                        )
570                    )
571                elif collisionMode == constants.WhitespaceCollision.SPLIT:
572                    # Left side of the split
573                    newEntryList.append(Interval(interval.start, start, interval.label))
574                    # Right side of the split
575                    newEntryList.append(
576                        (
577                            start + duration,
578                            start + duration + (interval.end - start),
579                            interval.label,
580                        )
581                    )
582                elif collisionMode == constants.WhitespaceCollision.NO_CHANGE:
583                    newEntryList.append(interval)
584                else:
585                    raise errors.ArgumentError(
586                        f"Collision occured during insertSpace() for interval '{interval}' "
587                        f"and given white space insertion interval ({start}, {start + duration})"
588                    )
589
590        newTier = self.new(
591            entries=newEntryList, maxTimestamp=self.maxTimestamp + duration
592        )
593
594        return newTier

Inserts a blank region into the tier

Arguments:
  • start:
  • duration:
  • collisionMode: Determines the behavior that occurs if an interval stradles the starting point
    • 'stretch' stretches the interval by /duration/ amount
    • 'split' splits the interval into two--everything to the right of 'start' will be advanced by 'duration' seconds
    • 'no change' leaves the interval as is with no change
    • 'error' will stop execution and raise an error
Returns:

the modified version of the current tier

def intersection( self, tier: IntervalTier, demarcator='-') -> IntervalTier:
596    def intersection(self, tier: "IntervalTier", demarcator="-") -> "IntervalTier":
597        """Takes the set intersection of this tier and the given one
598
599        - The output will contain one interval for each overlapping pair
600          e.g. [(1, 2, 'foo')] and [(1, 1.3, 'bang'), (1.7, 2, 'wizz')]
601                -> [(1, 1.3, 'foo-bang'), (1.7, 2, 'foo-wizz')]
602        - Only intervals that exist in both tiers will remain in the returned tier.
603          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
604                -> [(1, 2, 'foo-bang')]
605        - If intervals partially overlap, only the overlapping portion will be returned.
606          e.g. [(1, 2, 'foo')] and [(0.5, 1.5, 'bang')]
607                -> [(1, 1.5, 'foo-bang')]
608
609        Compare with IntervalTier.mergeLabels
610
611        Args:
612            tier: the tier to intersect with
613            demarcator: the character to separate the labels of the overlapping intervals
614
615        Returns:
616            IntervalTier: the modified version of the current tier
617        """
618        retEntryList = []
619        for interval in tier.entries:
620            subTier = self.crop(
621                interval.start, interval.end, CropCollision.TRUNCATED, False
622            )
623
624            # Combine the labels in the two tiers
625            subEntryList = [
626                (
627                    subInterval.start,
628                    subInterval.end,
629                    f"{subInterval.label}{demarcator}{interval.label}",
630                )
631                for subInterval in subTier.entries
632            ]
633
634            retEntryList.extend(subEntryList)
635
636        newName = f"{self.name}-{tier.name}"
637
638        retTier = self.new(newName, retEntryList)
639
640        return retTier

Takes the set intersection of this tier and the given one

  • The output will contain one interval for each overlapping pair e.g. [(1, 2, 'foo')] and [(1, 1.3, 'bang'), (1.7, 2, 'wizz')] -> [(1, 1.3, 'foo-bang'), (1.7, 2, 'foo-wizz')]
  • Only intervals that exist in both tiers will remain in the returned tier. e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')] -> [(1, 2, 'foo-bang')]
  • If intervals partially overlap, only the overlapping portion will be returned. e.g. [(1, 2, 'foo')] and [(0.5, 1.5, 'bang')] -> [(1, 1.5, 'foo-bang')]

Compare with IntervalTier.mergeLabels

Arguments:
  • tier: the tier to intersect with
  • demarcator: the character to separate the labels of the overlapping intervals
Returns:

IntervalTier: the modified version of the current tier

def mergeLabels( self, tier: IntervalTier, demarcator: str = ',') -> IntervalTier:
642    def mergeLabels(
643        self, tier: "IntervalTier", demarcator: str = ","
644    ) -> "IntervalTier":
645        """Merges labels of overlapping tiers into this tier
646
647        - All intervals in this tier will appear in the output; for the given tier, only intervals
648          that overlap with content in this tier will appear in the output
649          e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')]
650                -> [(1, 2, 'foo(bang)'), (3, 4, 'bar()')]
651        - If multiple entries exist in a subinterval, their labels will be concatenated
652          e.g. [(1, 2, 'hi')] and [(1, 1.5, 'h'), (1.5, 2, 'ai')] -> [(1, 2, 'hi(h,ai)')]
653
654        compare with IntervalTier.intersection
655
656        Args:
657            tier: the tier to intersect with
658            demarcator: the string to separate items that fall in the same subinterval
659
660        Returns:
661            IntervalTier: the modified version of the current tier
662        """
663        retEntryList = []
664        for interval in self.entries:
665            subTier = tier.crop(
666                interval.start, interval.end, CropCollision.TRUNCATED, False
667            )
668            if len(subTier._entries) == 0:
669                continue
670
671            subLabel = demarcator.join([entry.label for entry in subTier.entries])
672            label = f"{interval.label}({subLabel})"
673
674            start = min(interval.start, subTier._entries[0].start)
675            end = max(interval.end, subTier._entries[-1].end)
676
677            intersectedInterval = (
678                start,
679                end,
680                label,
681            )
682
683            retEntryList.append(intersectedInterval)
684
685        newName = f"{self.name}-{tier.name}"
686
687        retTier = self.new(newName, retEntryList)
688
689        return retTier

Merges labels of overlapping tiers into this tier

  • All intervals in this tier will appear in the output; for the given tier, only intervals that overlap with content in this tier will appear in the output e.g. [(1, 2, 'foo'), (3, 4, 'bar')] and [(1, 2, 'bang'), (2, 3, 'wizz')] -> [(1, 2, 'foo(bang)'), (3, 4, 'bar()')]
  • If multiple entries exist in a subinterval, their labels will be concatenated e.g. [(1, 2, 'hi')] and [(1, 1.5, 'h'), (1.5, 2, 'ai')] -> [(1, 2, 'hi(h,ai)')]

compare with IntervalTier.intersection

Arguments:
  • tier: the tier to intersect with
  • demarcator: the string to separate items that fall in the same subinterval
Returns:

IntervalTier: the modified version of the current tier

def morph( self, targetTier: IntervalTier, filterFunc: Optional[Callable[[str], bool]] = None) -> IntervalTier:
691    def morph(
692        self,
693        targetTier: "IntervalTier",
694        filterFunc: Optional[Callable[[str], bool]] = None,
695    ) -> "IntervalTier":
696        """Morphs the duration of segments in this tier to those in another
697
698        This preserves the labels and the duration of silence in
699        this tier while changing the duration of labeled segments.
700
701        Args:
702            targetTier:
703            filterFunc: if specified, filters entries. The
704                functor takes one argument, an Interval. It returns true
705                if the Interval should be modified and false if not.
706
707        Returns:
708            The modified version of the current tier
709        """
710        cumulativeAdjustAmount = 0
711        newEntryList = []
712        allIntervals = [self.entries, targetTier.entries]
713        for sourceInterval, targetInterval in utils.safeZip(allIntervals, True):
714            # sourceInterval.start - lastFromEnd -> was this interval and the
715            # last one adjacent?
716            newStart = sourceInterval.start + cumulativeAdjustAmount
717
718            currIntervalDuration = sourceInterval.end - sourceInterval.start
719            if filterFunc is None or filterFunc(sourceInterval.label):
720                newIntervalDuration = targetInterval.end - targetInterval.start
721                cumulativeAdjustAmount += newIntervalDuration - currIntervalDuration
722                newEnd = newStart + newIntervalDuration
723            else:
724                newEnd = newStart + currIntervalDuration
725
726            newEntryList.append(Interval(newStart, newEnd, sourceInterval.label))
727
728        newMin = self.minTimestamp
729        cumulativeDifference = newEntryList[-1].end - self.entries[-1].end
730        newMax = self.maxTimestamp + cumulativeDifference
731
732        return IntervalTier(self.name, newEntryList, newMin, newMax)

Morphs the duration of segments in this tier to those in another

This preserves the labels and the duration of silence in this tier while changing the duration of labeled segments.

Arguments:
  • targetTier:
  • filterFunc: if specified, filters entries. The functor takes one argument, an Interval. It returns true if the Interval should be modified and false if not.
Returns:

The modified version of the current tier

def validate( self, reportingMode: Literal['silence', 'warning', 'error'] = 'warning') -> bool:
734    def validate(
735        self, reportingMode: Literal["silence", "warning", "error"] = "warning"
736    ) -> bool:
737        """Validate this tier
738
739        Args:
740            reportingMode (str): Determines the behavior if validation fails.
741
742        Returns:
743            True if the tier is valid; False if not
744        """
745        utils.validateOption(
746            "reportingMode", reportingMode, constants.ErrorReportingMode
747        )
748        errorReporter = utils.getErrorReporter(reportingMode)
749
750        isValid = True
751        previousInterval = None
752        for interval in self.entries:
753            if interval.start >= interval.end:
754                isValid = False
755                errorReporter(
756                    errors.TextgridStateError,
757                    f"Invalid interval. End time occurs before or on the start time({interval}).",
758                )
759
760            if previousInterval and previousInterval.end > interval.start:
761                isValid = False
762                errorReporter(
763                    errors.TextgridStateError,
764                    f"Intervals are not sorted in time: "
765                    f"[({previousInterval}), ({interval})]",
766                )
767
768            if utils.checkIsUndershoot(
769                interval.start, self.minTimestamp, errorReporter
770            ):
771                isValid = False
772
773            if utils.checkIsOvershoot(interval.end, self.maxTimestamp, errorReporter):
774                isValid = False
775
776            previousInterval = interval
777
778        return isValid

Validate this tier

Arguments:
  • reportingMode (str): Determines the behavior if validation fails.
Returns:

True if the tier is valid; False if not