Skip to content

Frames Module / フレームモジュール

The wandas.frames module provides immutable Frame families for time-domain, frequency-domain, time-frequency, cepstral, octave-band, and roughness results.

wandas.framesは時間領域、周波数領域、時間周波数、ケプストラム、オクターブ帯域、 ラフネス結果を表すimmutableなFrameファミリーを提供します。

wandas.frames.channel.ChannelFrame

Bases: BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin

Channel-based data frame for handling audio signals and time series data.

This frame represents channel-based data such as audio signals and time series data, with each channel containing data samples in the time domain.

Source code in wandas/frames/channel.py
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
class ChannelFrame(BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin):
    """Channel-based data frame for handling audio signals and time series data.

    This frame represents channel-based data such as audio signals and time series data,
    with each channel containing data samples in the time domain.
    """

    _xarray_dim_suffix = ("channel", "time")

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: "BaseFrame[Any] | None" = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        """Initialize a ChannelFrame.

        Args:
            data: Dask array containing channel data.
                Shape should be (n_channels, n_samples).
            sampling_rate: The sampling rate of the data in Hz.
                Must be a positive value.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            lineage: Runtime operation lineage for this frame. This is the
                provenance source for executable replay and derived history
                views.
            channel_metadata: Metadata for each channel.
            previous: Immediate receiver Frame for process-local data comparison.
                For multi-input operations, follows only the left/base receiver.
                Not persisted in WDF.
            operation_history_prefix: Display history restored at a persistence boundary.

        Raises:
            ValueError: If data has more than 2 dimensions, or if
                sampling_rate is not positive.
        """
        # Validate and reshape data
        if data.ndim == 1:
            data = da.reshape(data, (1, -1))
        elif data.ndim > 2:
            raise ValueError(
                f"Invalid data shape for ChannelFrame\n"
                f"  Got: {data.shape} ({data.ndim}D)\n"
                f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
                f"If you have a 1D array, it will be automatically reshaped to\n"
                f"  (1, n_samples).\n"
                f"For higher-dimensional data, reshape it before creating\n"
                f"  ChannelFrame:\n"
                f"  Example: data.reshape(n_channels, -1)"
            )
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
            previous=previous,
        )

    def _set_channel_labels(self, ch_labels: list[str]) -> None:
        """Overwrite channel labels after construction.

        Raises ``ValueError`` if the list length does not match ``n_channels``.
        """
        if len(ch_labels) != self.n_channels:
            raise ValueError("Number of channel labels does not match the number of channels")
        labels = [_normalize_channel_label(label) for label in ch_labels]
        for i, lbl in enumerate(labels):
            self._set_channel_coord_value("channel_label", i, lbl)

    def _set_channel_units(self, ch_units: list[str]) -> None:
        """Overwrite channel units after construction.

        Raises ``ValueError`` if the list length does not match ``n_channels``.
        """
        if len(ch_units) != self.n_channels:
            raise ValueError("Number of channel units does not match the number of channels")
        for i, unit in enumerate(ch_units):
            self._set_channel_calibration(i, self.channels[i].calibration.with_unit(unit))

    def _finalize_channel_update(
        self,
        new_data: DaArray,
        new_chmeta: Sequence[ChannelMetadata | dict[str, Any]],
        channel_ids: list[str],
        source_time_offset: float | Sequence[float] | NDArrayReal | None = None,
        lineage: Any | None = None,
    ) -> "ChannelFrame":
        """Return an immutable channel update."""
        offsets = self.source_time_offset if source_time_offset is None else source_time_offset
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            channel_metadata=new_chmeta,
            channel_ids=channel_ids,
            source_time_offset=offsets,
            lineage=self.lineage if lineage is None else lineage,
            previous=self,
        )

    @property
    def time(self) -> NDArrayReal:
        """Get time array for the signal.

        The time array represents the start time of each sample, calculated as
        sample_index / sampling_rate. This provides a uniform, evenly-spaced
        time axis that is consistent across all frame types in wandas.

        For frames resulting from windowed analysis operations (e.g., FFT,
        loudness, roughness), each time point corresponds to the start of
        the analysis window, not the center. This differs from some libraries
        (e.g., MoSQITo) which use window center times, but does not affect
        the calculated values themselves.

        Returns:
            Array of time points in seconds, starting from 0.0.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("audio.wav")
            >>> time = signal.time
            >>> print(f"Duration: {time[-1]:.3f}s")
            >>> print(f"Time step: {time[1] - time[0]:.6f}s")
        """
        return np.arange(self.n_samples) / self.sampling_rate

    @property
    def source_time(self) -> NDArrayReal:
        """Get sample times relative to the original source timeline."""
        return self.source_time_offset[:, None] + self.time[None, :]

    @property
    def n_samples(self) -> int:
        """Returns the number of samples."""
        n: int = self._data.shape[-1]
        return n

    @property
    def duration(self) -> float:
        """Returns the duration in seconds."""
        return self.n_samples / self.sampling_rate

    def _resolve_calibration_updates(
        self,
        values: Sequence[float | ChannelCalibration] | Mapping[str | int, float | ChannelCalibration] | NDArrayReal,
    ) -> list[tuple[int, ChannelCalibration]]:
        """Resolve public list/dict intent against the current channel order."""

        def calibration_value(value: object, index: int) -> ChannelCalibration:
            if isinstance(value, ChannelCalibration):
                return value
            if isinstance(value, numbers.Real) and not isinstance(value, bool | np.bool_):
                return self.channels[index].calibration.with_factor(float(value))
            raise TypeError(
                "Invalid channel calibration value\n"
                f"  Channel: {self.channels[index].label!r} (index {index})\n"
                f"  Got: {type(value).__name__} ({value!r})\n"
                "  Expected: a positive factor or ChannelCalibration\n"
                "Pass a numeric factor to preserve unit/ref, or a complete typed value."
            )

        if isinstance(values, Mapping):
            if not values:
                raise ValueError(
                    "Empty calibration update\n"
                    "  Got: no channel entries\n"
                    "  Expected: at least one label or index\n"
                    "Pass a non-empty mapping, or a full list in channel order."
                )
            updates: list[tuple[int, ChannelCalibration]] = []
            resolved: set[int] = set()
            indices_by_label: dict[str, list[int]] = {}
            for index, label in enumerate(self.labels):
                indices_by_label.setdefault(label, []).append(index)
            for key, value in values.items():
                if isinstance(key, bool | np.bool_):
                    raise TypeError(
                        "Invalid calibration channel reference\n"
                        f"  Got: bool ({key!r})\n"
                        "  Expected: a channel label or integer index\n"
                        "Use the label string or its call-time position."
                    )
                if isinstance(key, numbers.Integral):
                    index = int(key)
                    index = index + self.n_channels if index < 0 else index
                    if not 0 <= index < self.n_channels:
                        raise IndexError(
                            "Calibration channel index out of range\n"
                            f"  Got: {key}\n"
                            f"  Expected: {-self.n_channels} <= index < {self.n_channels}\n"
                            "Use an index from the frame's current channel order."
                        )
                elif isinstance(key, str):
                    matches = indices_by_label.get(key, [])
                    if not matches:
                        raise KeyError(
                            "Unknown calibration channel label\n"
                            f"  Got: {key!r}\n"
                            f"  Available: {self.labels!r}\n"
                            "Use an exact current channel label."
                        )
                    if len(matches) > 1:
                        raise ValueError(
                            "Ambiguous calibration channel label\n"
                            f"  Got: {key!r} matches indices {matches}\n"
                            "  Expected: one uniquely identified channel\n"
                            "Rename duplicate labels or use integer indices."
                        )
                    index = matches[0]
                else:
                    raise TypeError(
                        "Invalid calibration channel reference\n"
                        f"  Got: {type(key).__name__} ({key!r})\n"
                        "  Expected: a channel label or integer index\n"
                        "Use strings and integers as mapping keys."
                    )
                if index in resolved:
                    raise ValueError(
                        "Duplicate calibration channel reference\n"
                        f"  Got: more than one entry resolves to index {index}\n"
                        "  Expected: each channel at most once per call\n"
                        "Remove the duplicate label/index entry."
                    )
                resolved.add(index)
                updates.append((index, calibration_value(value, index)))
            return updates

        if isinstance(values, np.ndarray):
            if values.ndim != 1:
                raise ValueError(
                    "Invalid calibration array shape\n"
                    f"  Got: {values.shape}\n"
                    "  Expected: one dimension with one value per channel\n"
                    "Flatten the coefficient column before configuring the frame."
                )
            sequence_values: Sequence[Any] = values.tolist()
        elif isinstance(values, Sequence) and not isinstance(values, str | bytes):
            sequence_values = values
        else:
            raise TypeError(
                "Invalid calibration values\n"
                f"  Got: {type(values).__name__}\n"
                "  Expected: an exact-length sequence, 1-D NumPy array, or a label/index mapping\n"
                "Pass [factor0, factor1] or {'channel': factor}."
            )
        if len(sequence_values) != self.n_channels:
            raise ValueError(
                "Calibration list length mismatch\n"
                f"  Got: {len(sequence_values)} values\n"
                f"  Expected: {self.n_channels} values in current channel order\n"
                "Provide one value per channel, or use a mapping for a partial update."
            )
        return [(index, calibration_value(value, index)) for index, value in enumerate(sequence_values)]

    def _with_calibration_by_id(
        self,
        calibrations: Mapping[str, ChannelCalibration],
    ) -> "ChannelFrame":
        """Apply already-resolved stable-ID updates under semantic capture."""
        lineage = self._required_semantic_lineage()
        available_ids = self._channel_ids
        available_id_set = set(available_ids)
        for channel_id in calibrations:
            if channel_id not in available_id_set:
                raise KeyError(
                    "Calibration Recipe channel is not present\n"
                    f"  Got stable ID: {channel_id!r}\n"
                    f"  Available IDs: {available_ids!r}\n"
                    "Replay the Recipe on a frame with the same channel identities."
                )
        metadata = self._borrowed_channel_metadata_descriptors(calibrations=calibrations)
        return self._create_new_instance(
            data=self._data,
            channel_metadata=metadata,
            channel_ids=available_ids,
            lineage=lineage,
        )

    @recipe_operation(
        "wandas.channel.with_calibration",
        bindings=_WITH_CALIBRATION_BINDINGS,
        capture=_capture_with_calibration,
        handler=_apply_with_calibration_recipe,
        validate_params=_validate_with_calibration_recipe,
    )
    def with_calibration(
        self,
        values: Sequence[float | ChannelCalibration] | Mapping[str | int, float | ChannelCalibration] | NDArrayReal,
    ) -> "ChannelFrame":
        """Return a frame configured with replacement per-channel calibrations.

        A sequence or one-dimensional NumPy array fully replaces factors in current
        channel order. A mapping partially updates labels and/or call-time indices.
        Numeric values replace only the factor; :class:`ChannelCalibration` replaces
        factor, unit, and ref. Stored samples stay raw and multiplication remains lazy.
        """
        lineage = self._required_semantic_lineage()
        operation = lineage.operation
        if (
            operation is not None
            and operation.operation_id == "wandas.channel.with_calibration"
            and operation.version == 1
        ):
            return cast("ChannelFrame", _apply_with_calibration_recipe((self,), thaw_params(operation.params)))
        updates = self._resolve_calibration_updates(values)
        calibrations = {self._channel_id_at(index): calibration for index, calibration in updates}
        return self._with_calibration_by_id(calibrations)

    def derive_calibration(
        self,
        *,
        target_rms: float | None = None,
        target_level: float | None = None,
        unit: str,
    ) -> dict[str, ChannelCalibration]:
        """Derive absolute per-channel calibration from this reference event.

        Exactly one known physical scalar is broadcast to every channel. The
        frame is not changed and no operation is added to its history.
        ``target_rms`` is a linear value in ``unit``. ``target_level`` is an
        amplitude level using ``20 * log10(target_rms / ref)`` and the default
        reference for ``unit``; for ``unit="Pa"`` that reference is ``2e-5 Pa``.
        """
        labels = self.labels
        if any(not label for label in labels) or len(set(labels)) != len(labels):
            raise ValueError("Calibration derivation requires unique non-empty channel labels")
        domain = ChannelCalibration(factor=1.0, unit=unit)
        factors = _derive_absolute_calibration_factors(
            self.rms,
            [channel.calibration.factor for channel in self.channels],
            target_rms=target_rms,
            target_level=target_level,
            ref=domain.ref,
        )
        return {
            label: ChannelCalibration(factor=factor, unit=domain.unit, ref=domain.ref)
            for label, factor in zip(labels, factors, strict=True)
        }

    @property
    def _float_data(self) -> DaArray:
        """Return data cast to float64 if not already floating-point.

        Prevents integer overflow when squaring (e.g. int16 samples).
        """
        data = self._effective_data
        if not np.issubdtype(data.dtype, np.floating):
            return data.astype(np.float64)
        return data

    @property
    def rms(self) -> NDArrayReal:
        """Calculate one linear RMS amplitude for each channel.

        This is a scalar reduction: it computes one value per channel and
        triggers immediate computation of the underlying Dask graph.  The
        result is a plain NumPy array and does **not** produce a new frame,
        so no runtime lineage or operation history view entry is created.
        Per-channel calibration factors are applied before the reduction, so
        each result uses that channel's physical unit. A calibrated ``Pa``
        channel therefore returns RMS pressure in Pa. This property never
        performs logarithmic conversion and must not be labeled dB or dB SPL.

        The RMS is defined as::

            rms[i] = sqrt(mean(x[i] ** 2))

        where ``x[i]`` is the sample array for channel ``i``.

        Returns:
            NDArrayReal of shape ``(n_channels,)`` containing the RMS value
                for each channel in its calibrated linear unit.

        Examples:
            >>> import wandas as wd
            >>> cf = wd.read("audio.wav")
            >>> rms_values = cf.rms
            >>> print(f"RMS values: {rms_values}")
            >>> # Select channels with RMS > threshold
            >>> active_channels = cf[cf.rms > 0.5]
        """
        # Compute RMS per channel.  axis=1 is the sample axis for data of
        # shape (channels, samples).  .compute() materialises the Dask graph
        # and np.array() ensures the result is a concrete NumPy ndarray.
        data = self._float_data
        rms_values = da.sqrt((data**2).mean(axis=1))
        return np.array(rms_values.compute())

    @property
    def crest_factor(self) -> NDArrayReal:
        """Calculate the crest factor (peak-to-RMS ratio) for each channel.

        This is a scalar reduction: it computes one value per channel and
        triggers immediate computation of the underlying Dask graph.  The
        result is a plain NumPy array and does **not** produce a new frame,
        so no runtime lineage or operation history view entry is created.

        The crest factor is defined as::

            crest_factor[i] = max(|x[i]|) / sqrt(mean(x[i] ** 2))

        where ``x[i]`` is the sample array for channel ``i``.

        For a pure sine wave the theoretical continuous-time crest factor is
        sqrt(2) ≈ 1.414; in discrete-time this implementation typically
        yields a value close to this, and exactly equal only when the sampled
        waveform contains its true peaks. Channels with zero RMS (all-zero
        signals) return 1.0 (defined by convention; no division by zero is
        performed).

        Returns:
            NDArrayReal of shape ``(n_channels,)`` containing the crest factor
                for each channel.  All-zero channels yield 1.0.

        Examples:
            >>> import wandas as wd
            >>> cf = wd.read("audio.wav")
            >>> cf_values = cf.crest_factor
            >>> print(f"Crest factors: {cf_values}")
            >>> # Select channels with crest factor above threshold
            >>> impulsive_channels = cf[cf.crest_factor > 3.0]
        """
        data = self._float_data
        peak = da.max(da.abs(data), axis=1)
        rms_vals = da.sqrt((data**2).mean(axis=1))
        # Use a safe denominator so the division never sees a zero RMS value,
        # then replace the result for zero-RMS channels with 1.0 by convention.
        safe_rms = da.where(rms_vals == 0, 1.0, rms_vals)
        crest = da.where(rms_vals != 0, peak / safe_rms, 1.0)
        return np.array(crest.compute())

    def info(self) -> None:
        """Display comprehensive information about the ChannelFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - Duration
        - Number of samples
        - Channel labels

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples:
            >>> import wandas as wd
            >>> cf = wd.read("audio.wav")
            >>> cf.info()
            Channels: 2
            Sampling rate: 44100 Hz
            Duration: 1.0 s
            Samples: 44100
            Channel labels: ['ch0', 'ch1']
        """
        print("ChannelFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  Duration: {self.duration:.1f} s")
        print(f"  Samples: {self.n_samples}")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """Construct and lazily apply one named processing operation."""
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        operation = create_operation(operation_name, self.sampling_rate, **params)
        return self._apply_operation_instance(operation, operation_name=operation_name)

    @recipe_operation(
        "wandas.audio.mix",
        binding_patterns=_MIX_INPUT_PATTERNS,
        capture=_capture_channel_input("other"),
        handler=_mix_recipe,
    )
    def mix(
        self,
        other: "ChannelFrame | NDArrayReal | DaArray",
        *,
        align: str = "strict",
        snr_db: float | None = None,
    ) -> "ChannelFrame":
        """Mix another signal lazily by array index.

        The base frame owns output length, channels, metadata, labels, and
        ``source_time_offset``. ``pad`` accepts only a shorter other signal;
        ``truncate`` accepts only a longer one.

        Args:
            other: A ChannelFrame or one-/two-dimensional NumPy or Dask array. A
                single channel broadcasts across the base channels; otherwise channel
                counts must match.
            align: Length policy. ``"strict"`` requires equal sample counts,
                ``"pad"`` zero-pads a shorter input, and ``"truncate"`` cuts a longer
                input to the base length.
            snr_db: Optional signal-to-noise ratio in decibels used to scale ``other``
                before addition. ``None`` performs direct addition.

        Returns:
            A new lazy ChannelFrame with the base frame's structure and metadata.

        Raises:
            TypeError: If ``other`` or ``snr_db`` has an unsupported type.
            ValueError: If sampling rates, dimensions, channel counts, lengths, or
                ``align`` do not satisfy the selected contract.

        Notes:
            Source-time offsets describe provenance and do not shift array positions.
            Signals from different source-time regions can therefore be mixed directly.
        """
        if align not in {"strict", "pad", "truncate"}:
            raise ValueError("align must be 'strict', 'pad', or 'truncate'")
        if snr_db is not None and not isinstance(snr_db, int | float | np.number):
            raise TypeError("snr_db must be numeric or None")

        if isinstance(other, ChannelFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    f"Sampling rate mismatch: {self.sampling_rate} Hz != {other.sampling_rate} Hz; resample first"
                )
            other_data = other._effective_data
        elif isinstance(other, np.ndarray):
            if other.ndim not in {1, 2}:
                raise ValueError("mix array input must be 1-D or channel-first 2-D")
            other_data = _da_from_array(other[None, :] if other.ndim == 1 else other, chunks=(1, -1))
        elif isinstance(other, DaArray):
            if other.ndim not in {1, 2}:
                raise ValueError("mix array input must be 1-D or channel-first 2-D")
            other_data = other[None, :] if other.ndim == 1 else other
        else:
            raise TypeError("mix requires a ChannelFrame, NumPy array, or Dask array; scalars are invalid")

        other_channels, other_samples = other_data.shape
        if other_channels not in {1, self.n_channels}:
            raise ValueError(
                f"mix channel count must match the base or be mono: base={self.n_channels}, other={other_channels}"
            )
        if align == "strict" and other_samples != self.n_samples:
            raise ValueError(f"strict mix requires equal lengths: base={self.n_samples}, other={other_samples}")
        if align == "pad":
            if other_samples >= self.n_samples:
                raise ValueError("pad mix requires the other signal to be shorter than the base")
            padding = da.zeros((other_channels, self.n_samples - other_samples), dtype=other_data.dtype)
            other_data = concatenate((other_data, padding), axis=1)
        if align == "truncate":
            if other_samples <= self.n_samples:
                raise ValueError("truncate mix requires the other signal to be longer than the base")
            other_data = other_data[:, : self.n_samples]

        if snr_db is None:
            result_data = self._effective_data + other_data
        else:
            from wandas.processing import create_operation

            operation = create_operation("add_with_snr", self.sampling_rate, snr=float(snr_db))
            result_data = operation.process(self._effective_data, other_data)

        return self._create_new_instance(
            data=result_data,
            channel_metadata=self._metadata_after_analysis(),
            lineage=self._required_semantic_lineage(),
        )

    def plot(
        self,
        plot_type: str = "waveform",
        ax: "Axes | None" = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        **kwargs: Any,
    ) -> "Axes | Iterator[Axes]":
        """Plot the frame data.

        Args:
            plot_type: Type of plot. Default is "waveform".
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot. If None, uses the frame label.
            overlay: Whether to overlay all channels on a single plot (True)
                or create separate subplots for each channel (False).
            xlabel: Label for the x-axis. If None, uses default based on plot type.
            ylabel: Label for the y-axis. If None, uses default based on plot type.
            alpha: Transparency level for the plot lines (0.0 to 1.0).
            xlim: Limits for the x-axis as (min, max) tuple.
            ylim: Limits for the y-axis as (min, max) tuple.
            **kwargs: Additional matplotlib Line2D parameters
                (e.g., color, linewidth, linestyle).
                These are passed to the underlying matplotlib plot functions.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> import wandas as wd
            >>> cf = wd.read("audio.wav")
            >>> # Basic plot
            >>> cf.plot()
            >>> # Overlay all channels
            >>> cf.plot(overlay=True, alpha=0.7)
            >>> # Custom styling
            >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
        """
        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        from ..visualization.plotting import create_operation

        plot_strategy = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def rms_plot(
        self,
        ax: "Axes | None" = None,
        title: str | None = None,
        overlay: bool = True,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> "Axes | Iterator[Axes]":
        """Plot a windowed RMS amplitude level in decibels.

        ``rms_plot()`` calls :meth:`rms_trend` with ``dB=True``. It is not a
        plot of the linear :attr:`rms` scalar. Values use
        ``20 * log10(window_rms / channel_ref)``; ``Aw=True`` applies the
        implemented digital A-weighting filter before the RMS calculation.
        The implementation has not been validated as a conforming sound-level
        meter.

        Args:
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot.
            overlay: Whether to overlay the plot on the existing axis.
            Aw: Apply the implemented A-frequency-weighting filter.
            **kwargs: Additional arguments passed to the plot() method.
                Accepts the same arguments as plot() including xlabel, ylabel,
                alpha, xlim, ylim, and matplotlib Line2D parameters.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> cf = wd.read("audio.wav")
            >>> # Basic RMS plot
            >>> cf.rms_plot()
            >>> # With A-weighting
            >>> cf.rms_plot(Aw=True)
            >>> # Custom styling
            >>> cf.rms_plot(ylabel="RMS level [dB re channel reference]", alpha=0.8, color="blue")
        """
        kwargs = kwargs or {}
        weighting = "A-weighted RMS level" if Aw else "RMS level"
        explicit_ylabel = "ylabel" in kwargs
        ylabel = kwargs.pop("ylabel", weighting)
        kwargs["_append_channel_units"] = not explicit_ylabel
        rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
        return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)

    def describe(
        self,
        normalize: bool = True,
        is_close: bool = True,
        *,
        fmin: float = 0,
        fmax: float | None = None,
        cmap: str = "jet",
        vmin: float | None = None,
        vmax: float | None = None,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        waveform: dict[str, Any] | None = None,
        spectral: dict[str, Any] | None = None,
        image_save: str | Path | None = None,
        **kwargs: Any,
    ) -> "list[Figure] | None":
        """Display visual and audio representation of the frame.

        This method creates a comprehensive visualization with three plots:
        1. Time-domain waveform (top)
        2. Spectrogram (bottom-left)
        3. Frequency spectrum via Welch method (bottom-right)

        Args:
            normalize: Whether to normalize the audio data for playback.
                Default: True
            is_close: Whether to close the figure after displaying.
                Default: True
            fmin: Minimum frequency to display in the spectrogram (Hz).
                Default: 0
            fmax: Maximum frequency to display in the spectrogram (Hz).
                Default: Nyquist frequency (sampling_rate / 2)
            cmap: Colormap for the spectrogram.
                Default: 'jet'
            vmin: Minimum value for spectrogram color scale (dB).
                Auto-calculated if None.
            vmax: Maximum value for spectrogram color scale (dB).
                Auto-calculated if None.
            xlim: Time axis limits (seconds) for all time-based plots.
                Format: (start_time, end_time)
            ylim: Frequency axis limits (Hz) for frequency-based plots.
                Format: (min_freq, max_freq)
            Aw: Apply A-weighting to the frequency analysis.
                Default: False
            waveform: Additional configuration dict for waveform subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            spectral: Additional configuration dict for spectral subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            image_save: Path to save the figure as an image file. If provided,
                the figure will be saved before closing. File format is determined
                from the extension (e.g., '.png', '.jpg', '.pdf'). For multi-channel
                frames, the channel index is appended to the filename stem
                (e.g., 'output_0.png', 'output_1.png'). Default: None.
            **kwargs: Deprecated parameters for backward compatibility only.
                - axis_config: Old configuration format (use waveform/spectral instead)
                - cbar_config: Old colorbar configuration (use vmin/vmax instead)

        Returns:
            None (default). When `is_close=False`, returns a list of matplotlib Figure
                objects created for each channel. The list length equals the number of
                channels in the frame.

        Examples:
            >>> cf = wd.read("audio.wav")
            >>> # Basic usage
            >>> cf.describe()
            >>>
            >>> # Custom frequency range
            >>> cf.describe(fmin=100, fmax=5000)
            >>>
            >>> # Custom color scale
            >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
            >>>
            >>> # A-weighted analysis
            >>> cf.describe(Aw=True)
            >>>
            >>> # Custom time range
            >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
            >>>
            >>> # Custom waveform subplot settings
            >>> cf.describe(waveform={"ylabel": "Custom Label"})
            >>>
            >>> # Save the figure to a file
            >>> cf.describe(image_save="output.png")
            >>>
            >>> # Get Figure objects for further manipulation (is_close=False)
            >>> figures = cf.describe(is_close=False)
            >>> fig = figures[0]
            >>> fig.savefig("custom_output.png")  # Custom save with modifications
        """
        from wandas.visualization.describe import describe_frame

        return describe_frame(
            self,
            normalize=normalize,
            is_close=is_close,
            fmin=fmin,
            fmax=fmax,
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            xlim=xlim,
            ylim=ylim,
            Aw=Aw,
            waveform=waveform,
            spectral=spectral,
            image_save=image_save,
            **kwargs,
        )

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayReal,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        ch_labels: list[str] | None = None,
        ch_units: list[str] | str | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        Args:
            data: NumPy array containing channel data.
            sampling_rate: The sampling rate in Hz.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            ch_labels: Labels for each channel.
            ch_units: Units for each channel.

        Returns:
            A new ChannelFrame containing the NumPy data.
        """
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim > 2:
            raise ValueError(f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}")

        # Convert NumPy array to dask array. Use channel-wise chunks so
        # the 0th axis (channels) is chunked per-channel and the sample
        # axis remains un-chunked by default.
        dask_data = _da_from_array(data, chunks=(1, -1))
        cf = cls(
            data=dask_data,
            sampling_rate=sampling_rate,
            label=label or "numpy_data",
            metadata=metadata,
        )
        if ch_labels is not None:
            cf._set_channel_labels(ch_labels)
        if ch_units is not None:
            if isinstance(ch_units, str):
                ch_units = [ch_units] * cf.n_channels
            cf._set_channel_units(ch_units)

        return cf

    @classmethod
    def from_ndarray(
        cls,
        array: NDArrayReal,
        sampling_rate: float,
        labels: list[str] | None = None,
        unit: list[str] | str | None = None,
        frame_label: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        This method is deprecated. Use from_numpy instead.

        Args:
            array: Signal data. Each row corresponds to a channel.
            sampling_rate: Sampling rate (Hz).
            labels: Labels for each channel.
            unit: Unit of the signal.
            frame_label: Label for the frame.
            metadata: Optional metadata dictionary.

        Returns:
            A new ChannelFrame containing the data.
        """
        warnings.warn(
            "from_ndarray is deprecated. Use from_numpy instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return cls.from_numpy(
            data=array,
            sampling_rate=sampling_rate,
            label=frame_label,
            metadata=metadata,
            ch_labels=labels,
            ch_units=unit,
        )

    @classmethod
    def from_file(
        cls,
        path: str | Path | bytes | bytearray | memoryview | BinaryIO,
        channel: int | list[int] | None = None,
        start: float | None = None,
        end: float | None = None,
        # NOTE: chunk_size removed — chunking is handled internally as
        # channel-wise (1, -1). This simplifies the API and prevents
        # users from accidentally breaking channel-wise parallelism.
        ch_labels: list[str] | None = None,
        # CSV-specific parameters
        time_column: int | str = 0,
        delimiter: str = ",",
        header: int | None = 0,
        file_type: str | None = None,
        source_name: str | None = None,
        timeout: float = 10.0,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from an audio file or URL.

        Note:
            The `chunk_size` parameter has been removed. ChannelFrame uses
            channel-wise chunking by default (chunks=(1, -1)). Use `.rechunk(...)`
            on the returned frame for custom sample-axis chunking.

            Audio sample decoding is deferred through the returned Dask array.
            CSV metadata inspection synchronously parses the complete table to
            determine shape and sampling rate; the sample table is parsed again
            when the Dask data is computed.

        Args:
            path: Path to the audio file, in-memory bytes/stream, or an HTTP/HTTPS
                URL. When a URL is given it is streamed into a temporary file
                before processing, subject to the maximum download size
                enforced by `wandas.io.readers.MAX_URL_DOWNLOAD_BYTES`.
                Oversized URL downloads fail before loading completes. The file
                extension is inferred from the URL path; supply `file_type`
                explicitly when the URL has no recognisable extension. If you
                need to allow larger URL downloads, increase
                `wandas.io.readers.MAX_URL_DOWNLOAD_BYTES` before calling this
                method.
            channel: Channel(s) to load. None loads all channels.
            start: Start time in seconds.
            end: End time in seconds.
            ch_labels: Labels for each channel.
            time_column: For CSV files, index or name of the time column.
                Default is 0 (first column).
            delimiter: For CSV files, delimiter character. Default is ",".
            header: For CSV files, row number to use as header.
                Default is 0 (first row). Set to None if no header.
            file_type: File extension for in-memory data or URLs without a
                recognisable extension (e.g. ".wav", ".csv").
            source_name: Optional source name for in-memory data. Used in metadata.
            timeout: Timeout in seconds for HTTP/HTTPS URL downloads. Default is
                10.0 seconds. Has no effect for local files or in-memory data.

        Returns:
            A new ChannelFrame containing the loaded data. SoundFile-backed
                audio channels use the explicit linear unit ``FS`` and
                reference 1. CSV channels remain generic.

        Raises:
            ValueError: If channel specification is invalid or file cannot be read.
                Error message includes absolute path, current directory, and
                troubleshooting suggestions.

        Examples:
            >>> import wandas as wd
            >>> # Load WAV file as full-scale float64 audio
            >>> cf = wd.read("audio.wav")
            >>> # Load specific channels
            >>> cf = wd.read("audio.wav", channel=[0, 2])
            >>> # Load CSV file
            >>> cf = wd.read("data.csv", time_column=0, delimiter=",", header=0)
            >>> # Load from a URL
            >>> cf = wd.read("https://example.com/audio.wav")
        """
        from .channel import ChannelFrame

        download_owner: DownloadedTemporaryFile | None = None
        downloaded_from_url = False

        # Validate optional CSV dependencies before starting a remote download.
        if isinstance(path, str) and path.lower().startswith(("http://", "https://")):
            url_file_type = file_type
            if url_file_type is None:
                from pathlib import PurePosixPath
                from urllib.parse import urlparse

                url_file_type = PurePosixPath(urlparse(path).path).suffix.lower() or None
            if url_file_type is not None and url_file_type.lower().lstrip(".") == "csv":
                require_pandas("CSV file reading")

            path, download_owner, file_type, source_name = _download_url(path, file_type, source_name, timeout)
            downloaded_from_url = True

        try:
            source_obj, path_obj, reader, normalized_file_type = _resolve_source(path, file_type)
        except Exception:
            if download_owner is not None:
                download_owner.cleanup()
            raise

        # Build kwargs for reader
        reader_kwargs: dict[str, Any] = {}
        if (path_obj is not None and path_obj.suffix.lower() == ".csv") or (normalized_file_type == ".csv"):
            reader_kwargs["time_column"] = time_column
            reader_kwargs["delimiter"] = delimiter
            reader_kwargs["header"] = header

        try:
            info = reader.get_file_info(source_obj, **reader_kwargs)
        except Exception:
            if download_owner is not None:
                download_owner.cleanup()
            raise
        sr = info["samplerate"]
        n_channels = info["channels"]
        n_frames = info["frames"]
        ch_labels = ch_labels or info.get("ch_labels", None)
        source_time_start = float(info.get("time_start", 0.0))

        logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

        # Channel selection processing
        try:
            channels_to_load = _resolve_channels(channel, n_channels)
        except Exception:
            if download_owner is not None:
                download_owner.cleanup()
            raise

        # Index calculation
        start_idx = 0 if start is None else max(0, int(start * sr))
        end_idx = n_frames if end is None else min(n_frames, int(end * sr))
        frames_to_read = end_idx - start_idx

        logger.debug(
            f"Setting up lazy load from file={path!r}, frames={frames_to_read}, "
            f"start_idx={start_idx}, end_idx={end_idx}"
        )

        # Settings for lazy loading
        expected_shape = (len(channels_to_load), frames_to_read)

        captured_download_owner = download_owner

        # Define the loading function using the file reader
        def _load_audio() -> NDArrayReal:
            """Read the selected file segment when Dask executes the delayed task."""
            logger.debug(">>> EXECUTING DELAYED LOAD <<<")
            # Log the temporary download path so this closure keeps ownership of
            # the streamed file until the delayed read completes.
            if captured_download_owner is not None:
                logger.debug("Reading from streamed temporary download %s", captured_download_owner.path)
            # Use the reader to get audio data with parameters
            out = reader.get_data(
                source_obj,
                channels_to_load,
                start_idx,
                frames_to_read,
                **reader_kwargs,
            )
            if not isinstance(out, np.ndarray):
                raise ValueError("Unexpected data type after reading file")
            if out.shape != expected_shape:
                raise ValueError(
                    "Reader returned an unexpected channel-first shape\n"
                    f"  Got: {out.shape}\n"
                    f"  Expected: {expected_shape}"
                )
            if not np.issubdtype(out.dtype, np.number) or np.issubdtype(out.dtype, np.complexfloating):
                raise TypeError("Readers must return a real channel-first numeric array")
            return out.astype(np.float64, copy=False)

        logger.debug(f"Creating delayed dask task with expected shape: {expected_shape}")

        # Create delayed operation
        try:
            delayed_data = dask_delayed(_load_audio)()
            logger.debug("Wrapping delayed function in dask array")

            # Create dask array from delayed computation and ensure channel-wise
            # chunks. The sample axis (1) uses -1 by default to avoid forcing
            # a sample chunk length here.
            dask_array = da_from_delayed(delayed_data, shape=expected_shape, dtype=np.float64)

            # Ensure channel-wise chunks
            dask_array = dask_array.rechunk((1, -1))
        except Exception:
            if download_owner is not None:
                download_owner.cleanup()
            raise

        logger.debug("ChannelFrame setup complete - actual file reading will occur on compute()")

        if source_name is not None:
            try:
                if source_name.lower().startswith(("http://", "https://")):
                    from pathlib import PurePosixPath
                    from urllib.parse import urlparse

                    frame_label = PurePosixPath(urlparse(source_name).path).stem
                else:
                    frame_label = Path(source_name).stem
            except (TypeError, ValueError, OSError):
                logger.debug(
                    "Using raw source_name as frame label because Path(source_name) failed; source_name=%r",
                    source_name,
                )
                frame_label = source_name
        elif path_obj is not None:
            frame_label = path_obj.stem
        else:
            frame_label = None
        source_file: str | None = None
        if downloaded_from_url and source_name is not None:
            source_file = source_name
        elif path_obj is not None:
            source_file = str(path_obj.resolve())
        elif source_name is not None:
            source_file = source_name

        try:
            channel_metadata = None
            if info.get("unit") == "FS":
                channel_metadata = [
                    ChannelMetadata(
                        label=f"ch{index}",
                        calibration=ChannelCalibration(unit="FS", ref=1.0),
                    )
                    for index, _ in enumerate(channels_to_load)
                ]
            cf = ChannelFrame(
                data=dask_array,
                sampling_rate=sr,
                label=frame_label,
                metadata={"_source_file": source_file} if source_file is not None else None,
                channel_metadata=channel_metadata,
                source_time_offset=source_time_start + start_idx / sr,
            )
            if ch_labels is not None:
                cf._set_channel_labels(ch_labels)
        except Exception:
            if download_owner is not None:
                download_owner.cleanup()
            raise
        return cf

    @classmethod
    def read_wav(
        cls,
        filename: str | Path | bytes | bytearray | memoryview | BinaryIO,
        labels: list[str] | None = None,
    ) -> "ChannelFrame":
        """Utility method to read a WAV file.

        Args:
            filename: Path to the WAV file or in-memory bytes/stream.
            labels: Labels to set for each channel.

        Returns:
            A new ChannelFrame containing the data (lazy loading).
        """
        from .channel import ChannelFrame

        is_in_memory = isinstance(filename, (bytes, bytearray, memoryview)) or _is_file_like(filename)
        source_name: str | None = None
        if is_in_memory and _is_file_like(filename):
            source_name = getattr(filename, "name", None)
        cf = ChannelFrame.from_file(
            filename,
            ch_labels=labels,
            file_type=".wav" if is_in_memory else None,
            source_name=source_name,
        )
        return cf

    @classmethod
    def read_csv(
        cls,
        filename: str,
        time_column: int | str = 0,
        labels: list[str] | None = None,
        delimiter: str = ",",
        header: int | None = 0,
    ) -> "ChannelFrame":
        """Utility method to read a CSV file.

        Args:
            filename: Path to the CSV file.
            time_column: Index or name of the time column.
            labels: Labels to set for each channel.
            delimiter: Delimiter character.
            header: Row number to use as header.

        Returns:
            A new ChannelFrame containing Dask-backed sample data. CSV metadata
                inspection occurs synchronously before the Frame is returned.

        Examples:
            >>> # Read CSV with default settings
            >>> cf = wd.read("data.csv")
            >>> # Read CSV with custom delimiter
            >>> cf = wd.read("data.csv", delimiter=";")
            >>> # Read CSV without header
            >>> cf = wd.read("data.csv", header=None)
        """
        from .channel import ChannelFrame

        cf = ChannelFrame.from_file(
            filename,
            ch_labels=labels,
            time_column=time_column,
            delimiter=delimiter,
            header=header,
        )
        return cf

    def to_wav(self, path: str | Path, format: str | None = None) -> None:
        """Save the audio data to a WAV file.

        Args:
            path: Path to save the file.
            format: File format. If None, determined from file extension.
        """
        from wandas.io.wav_io import write_wav

        write_wav(str(path), self, format=format)

    @classmethod
    def load(cls, path: str | Path) -> "ChannelFrame":
        """Load a ChannelFrame from a WDF (Wandas Data File) file.

        This loads data saved with the save() method, preserving all channel data,
        metadata, labels, and units.

        Args:
            path: Path to the WDF file

        Returns:
            A new ChannelFrame with all data and metadata loaded

        Raises:
            FileNotFoundError: If the file doesn't exist

        Examples:
            >>> import wandas as wd
            >>> cf = wd.load("audio_analysis.wdf")
        """
        from ..io.wdf_io import load as wdf_load

        loaded = wdf_load(path)
        if not isinstance(loaded, ChannelFrame):
            raise TypeError(
                "ChannelFrame.load() received a different typed WDF Frame\n"
                f"  Got: {type(loaded).__name__}\n"
                "  Expected: ChannelFrame\n"
                "Use wd.load() when the stored Frame type is not known in advance."
            )
        return loaded

    @_normalize_add_channel_call
    @recipe_operation(
        "wandas.channel.add_channel",
        version=2,
        bindings=_ADD_CHANNEL_BINDINGS,
        capture=_capture_add_channel,
        handler=_add_channel_recipe,
        validate_params=_validate_add_channel_recipe,
    )
    def add_channel(
        self,
        data: "np.ndarray[Any, Any] | DaArray",
        label: str | None = None,
        align: str = "strict",
        suffix_on_dup: str | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal | None = None,
    ) -> "ChannelFrame":
        """Add a new channel to the frame.

        Args:
            data: NumPy or Dask data for exactly one channel. The accepted shapes
                are ``(samples,)`` and ``(1, samples)``.
            label: Label for the new channel. If None, generates a default label.
            align: How to handle length mismatches:
                - "strict": Raise error if lengths don't match
                - "pad": Pad shorter data with zeros
                - "truncate": Truncate longer data to match
            suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
            source_time_offset: Offset in seconds for the new channel. Accepts
                a finite real scalar or a one-item 1-D sequence/NumPy array.
                If None, the new channel uses 0.0. Accepted forms are normalized
                to a built-in float before execution and Recipe capture.

        Returns:
            A new ChannelFrame.

        Raises:
            ValueError: If data length doesn't match and align="strict",
                or if label is duplicate and suffix_on_dup is None.
            TypeError: If data is not a NumPy or Dask array. Pass another
                ChannelFrame to :meth:`concat_frame` instead.

        Examples:
            >>> cf = wd.read("audio.wav")
            >>> # Add a numpy array as a new channel
            >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
            >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
            >>> # Concatenate another ChannelFrame's channels
            >>> cf2 = wd.read("audio2.wav")
            >>> cf_combined = cf.concat_frame(cf2)
        """
        normalized_params = _normalize_channel_operation_params(
            {
                "label": label,
                "align": align,
                "suffix_on_dup": suffix_on_dup,
                "source_time_offset": source_time_offset,
            },
            label_name="label",
            allow_source_time_offset=True,
        )
        source_time_offset = cast(float, normalized_params["source_time_offset"])
        if isinstance(data, ChannelFrame):
            raise TypeError(
                "add_channel() no longer accepts ChannelFrame input; use concat_frame(other, label_prefix=...) instead"
            )
        if isinstance(data, np.ndarray):
            if data.ndim == 1:
                data = data[None, :]
            elif data.ndim != 2 or data.shape[0] != 1:
                raise ValueError("Raw add_channel input must be 1-D or shaped (1, samples)")
            arr = _da_from_array(data, chunks=(1, -1))
        elif isinstance(data, DaArray):
            if data.ndim == 1:
                arr = data[None, :]
            elif data.ndim == 2 and data.shape[0] == 1:
                arr = data
            else:
                raise ValueError("Raw add_channel input must be 1-D or shaped (1, samples)")
        else:
            raise TypeError("add_channel() data must be a NumPy array or Dask array")
        arr = _align_to_length(arr, self.n_samples, align, arr.shape[1])
        labels = self.labels
        new_label = label or f"ch{len(labels)}"
        if new_label in labels:
            if suffix_on_dup:
                new_label += suffix_on_dup
            else:
                raise ValueError(
                    f"Duplicate channel label\n"
                    f"  Label: '{new_label}'\n"
                    f"  Existing labels: {labels}\n"
                    f"Use suffix_on_dup parameter to automatically "
                    f"rename duplicates."
                )
        new_data = concatenate([self._data, arr], axis=0)

        new_ids = [*self._channel_ids, self._next_channel_id()]
        new_chmeta = [*self._borrowed_channel_metadata_descriptors(), ChannelMetadata(label=new_label)]
        new_channel_offsets = np.asarray([source_time_offset], dtype=float)
        new_offsets = np.concatenate([self.source_time_offset, new_channel_offsets])
        return self._finalize_channel_update(
            new_data,
            new_chmeta,
            new_ids,
            new_offsets,
            lineage=self._required_semantic_lineage(),
        )

    @recipe_operation(
        "wandas.channel.concat_frame",
        bindings=_CONCAT_FRAME_BINDINGS,
        capture=_capture_concat_frame,
        handler=_concat_frame_recipe,
        validate_params=_validate_concat_frame_recipe,
    )
    def concat_frame(
        self,
        other: "ChannelFrame",
        label_prefix: str | None = None,
        align: str = "strict",
        suffix_on_dup: str | None = None,
    ) -> "ChannelFrame":
        """Concatenate all channels from another frame along the channel axis.

        The result preserves ``other`` channel metadata, calibration, and
        source-time offsets. Neither input frame is changed.

        Args:
            other: ChannelFrame whose channels are appended in their current order.
            label_prefix: Optional prefix for appended labels, producing
                ``"{label_prefix}_{original_label}"``.
            align: ``"strict"`` rejects sample-length differences; ``"pad"`` and
                ``"truncate"`` align the appended data to this frame's length.
            suffix_on_dup: Suffix for duplicate labels. If None, duplicates raise.

        Returns:
            A new lazy ChannelFrame containing both channel collections.

        Raises:
            TypeError: If other is not a ChannelFrame.
            ValueError: If sampling rates, lengths, or labels are incompatible.
        """
        _normalize_channel_operation_params(
            {
                "label_prefix": label_prefix,
                "align": align,
                "suffix_on_dup": suffix_on_dup,
            },
            label_name="label_prefix",
            allow_source_time_offset=False,
        )
        if not isinstance(other, ChannelFrame):
            raise TypeError("concat_frame() other must be a ChannelFrame")
        if self.sampling_rate != other.sampling_rate:
            raise ValueError("sampling_rate mismatch")
        arr = _align_to_length(other._data, self.n_samples, align, other.n_samples)
        labels = self.labels
        new_labels: list[str] = []
        new_metadata_list = other._borrowed_channel_metadata_descriptors()
        for descriptor, chmeta in zip(new_metadata_list, other.channels, strict=True):
            new_label = f"{label_prefix}_{chmeta.label}" if label_prefix is not None else chmeta.label
            if new_label in labels or new_label in new_labels:
                if suffix_on_dup:
                    new_label += suffix_on_dup
                else:
                    raise ValueError(
                        f"Duplicate channel label\n"
                        f"  Label: '{new_label}'\n"
                        f"  Existing labels: {labels + new_labels}\n"
                        f"Use suffix_on_dup parameter to automatically "
                        f"rename duplicates."
                    )
            new_labels.append(new_label)
            descriptor["label"] = new_label
        new_data = concatenate([self._data, arr], axis=0)
        new_chmeta = self._borrowed_channel_metadata_descriptors() + new_metadata_list
        new_ids = self._channel_ids.copy()
        for _ in new_metadata_list:
            new_ids.append(self._next_channel_id(new_ids))
        new_offsets = np.concatenate([self.source_time_offset, other.source_time_offset])
        return self._finalize_channel_update(
            new_data,
            new_chmeta,
            new_ids,
            new_offsets,
            lineage=self._required_semantic_lineage(),
        )

    @recipe_operation("wandas.channel.remove_channel")
    def remove_channel(self, key: int | str) -> "ChannelFrame":
        """Return a new frame without one channel.

        Args:
            key: Zero-based channel index or exact channel label to remove.

        Returns:
            A lazy ChannelFrame preserving the remaining channels' metadata, stable
                channel identifiers, source-time offsets, and semantic lineage.

        Raises:
            IndexError: If an integer index is outside the channel range.
            KeyError: If a string label does not exist.
        """
        if isinstance(key, int):
            if not (0 <= key < self.n_channels):
                raise IndexError(f"index {key} out of range")
            idx = key
        else:
            labels = self.labels
            if key not in labels:
                raise KeyError(f"label {key} not found")
            idx = labels.index(key)
        keep_indices = [i for i in range(self.n_channels) if i != idx]
        new_data = self._data[keep_indices, :]
        new_chmeta = self._borrowed_channel_metadata_descriptors(keep_indices)
        new_ids = [self._channel_ids[i] for i in keep_indices]
        return self._finalize_channel_update(
            new_data,
            new_chmeta,
            new_ids,
            self.source_time_offset[keep_indices],
            lineage=self._required_semantic_lineage(),
        )

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get time index for DataFrame."""
        pd = require_pandas("ChannelFrame.to_dataframe")
        return pd.Index(self.time, name="time")

Attributes

time property

Get time array for the signal.

The time array represents the start time of each sample, calculated as sample_index / sampling_rate. This provides a uniform, evenly-spaced time axis that is consistent across all frame types in wandas.

For frames resulting from windowed analysis operations (e.g., FFT, loudness, roughness), each time point corresponds to the start of the analysis window, not the center. This differs from some libraries (e.g., MoSQITo) which use window center times, but does not affect the calculated values themselves.

Returns:

Type Description
NDArrayReal

Array of time points in seconds, starting from 0.0.

Examples:

>>> import wandas as wd
>>> signal = wd.read("audio.wav")
>>> time = signal.time
>>> print(f"Duration: {time[-1]:.3f}s")
>>> print(f"Time step: {time[1] - time[0]:.6f}s")

source_time property

Get sample times relative to the original source timeline.

n_samples property

Returns the number of samples.

duration property

Returns the duration in seconds.

rms property

Calculate one linear RMS amplitude for each channel.

This is a scalar reduction: it computes one value per channel and triggers immediate computation of the underlying Dask graph. The result is a plain NumPy array and does not produce a new frame, so no runtime lineage or operation history view entry is created. Per-channel calibration factors are applied before the reduction, so each result uses that channel's physical unit. A calibrated Pa channel therefore returns RMS pressure in Pa. This property never performs logarithmic conversion and must not be labeled dB or dB SPL.

The RMS is defined as::

rms[i] = sqrt(mean(x[i] ** 2))

where x[i] is the sample array for channel i.

Returns:

Type Description
NDArrayReal

NDArrayReal of shape (n_channels,) containing the RMS value for each channel in its calibrated linear unit.

Examples:

>>> import wandas as wd
>>> cf = wd.read("audio.wav")
>>> rms_values = cf.rms
>>> print(f"RMS values: {rms_values}")
>>> # Select channels with RMS > threshold
>>> active_channels = cf[cf.rms > 0.5]

crest_factor property

Calculate the crest factor (peak-to-RMS ratio) for each channel.

This is a scalar reduction: it computes one value per channel and triggers immediate computation of the underlying Dask graph. The result is a plain NumPy array and does not produce a new frame, so no runtime lineage or operation history view entry is created.

The crest factor is defined as::

crest_factor[i] = max(|x[i]|) / sqrt(mean(x[i] ** 2))

where x[i] is the sample array for channel i.

For a pure sine wave the theoretical continuous-time crest factor is sqrt(2) ≈ 1.414; in discrete-time this implementation typically yields a value close to this, and exactly equal only when the sampled waveform contains its true peaks. Channels with zero RMS (all-zero signals) return 1.0 (defined by convention; no division by zero is performed).

Returns:

Type Description
NDArrayReal

NDArrayReal of shape (n_channels,) containing the crest factor for each channel. All-zero channels yield 1.0.

Examples:

>>> import wandas as wd
>>> cf = wd.read("audio.wav")
>>> cf_values = cf.crest_factor
>>> print(f"Crest factors: {cf_values}")
>>> # Select channels with crest factor above threshold
>>> impulsive_channels = cf[cf.crest_factor > 3.0]

Functions

__init__(data, sampling_rate, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Initialize a ChannelFrame.

Parameters:

Name Type Description Default
data Array

Dask array containing channel data. Shape should be (n_channels, n_samples).

required
sampling_rate float

The sampling rate of the data in Hz. Must be a positive value.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
lineage Any | None

Runtime operation lineage for this frame. This is the provenance source for executable replay and derived history views.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

Metadata for each channel.

None
previous BaseFrame[Any] | None

Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None
operation_history_prefix Sequence[Mapping[str, Any]]

Display history restored at a persistence boundary.

()

Raises:

Type Description
ValueError

If data has more than 2 dimensions, or if sampling_rate is not positive.

Source code in wandas/frames/channel.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: "BaseFrame[Any] | None" = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    """Initialize a ChannelFrame.

    Args:
        data: Dask array containing channel data.
            Shape should be (n_channels, n_samples).
        sampling_rate: The sampling rate of the data in Hz.
            Must be a positive value.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        lineage: Runtime operation lineage for this frame. This is the
            provenance source for executable replay and derived history
            views.
        channel_metadata: Metadata for each channel.
        previous: Immediate receiver Frame for process-local data comparison.
            For multi-input operations, follows only the left/base receiver.
            Not persisted in WDF.
        operation_history_prefix: Display history restored at a persistence boundary.

    Raises:
        ValueError: If data has more than 2 dimensions, or if
            sampling_rate is not positive.
    """
    # Validate and reshape data
    if data.ndim == 1:
        data = da.reshape(data, (1, -1))
    elif data.ndim > 2:
        raise ValueError(
            f"Invalid data shape for ChannelFrame\n"
            f"  Got: {data.shape} ({data.ndim}D)\n"
            f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
            f"If you have a 1D array, it will be automatically reshaped to\n"
            f"  (1, n_samples).\n"
            f"For higher-dimensional data, reshape it before creating\n"
            f"  ChannelFrame:\n"
            f"  Example: data.reshape(n_channels, -1)"
        )
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
        previous=previous,
    )

with_calibration(values)

Return a frame configured with replacement per-channel calibrations.

A sequence or one-dimensional NumPy array fully replaces factors in current channel order. A mapping partially updates labels and/or call-time indices. Numeric values replace only the factor; :class:ChannelCalibration replaces factor, unit, and ref. Stored samples stay raw and multiplication remains lazy.

Source code in wandas/frames/channel.py
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
@recipe_operation(
    "wandas.channel.with_calibration",
    bindings=_WITH_CALIBRATION_BINDINGS,
    capture=_capture_with_calibration,
    handler=_apply_with_calibration_recipe,
    validate_params=_validate_with_calibration_recipe,
)
def with_calibration(
    self,
    values: Sequence[float | ChannelCalibration] | Mapping[str | int, float | ChannelCalibration] | NDArrayReal,
) -> "ChannelFrame":
    """Return a frame configured with replacement per-channel calibrations.

    A sequence or one-dimensional NumPy array fully replaces factors in current
    channel order. A mapping partially updates labels and/or call-time indices.
    Numeric values replace only the factor; :class:`ChannelCalibration` replaces
    factor, unit, and ref. Stored samples stay raw and multiplication remains lazy.
    """
    lineage = self._required_semantic_lineage()
    operation = lineage.operation
    if (
        operation is not None
        and operation.operation_id == "wandas.channel.with_calibration"
        and operation.version == 1
    ):
        return cast("ChannelFrame", _apply_with_calibration_recipe((self,), thaw_params(operation.params)))
    updates = self._resolve_calibration_updates(values)
    calibrations = {self._channel_id_at(index): calibration for index, calibration in updates}
    return self._with_calibration_by_id(calibrations)

derive_calibration(*, target_rms=None, target_level=None, unit)

Derive absolute per-channel calibration from this reference event.

Exactly one known physical scalar is broadcast to every channel. The frame is not changed and no operation is added to its history. target_rms is a linear value in unit. target_level is an amplitude level using 20 * log10(target_rms / ref) and the default reference for unit; for unit="Pa" that reference is 2e-5 Pa.

Source code in wandas/frames/channel.py
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
def derive_calibration(
    self,
    *,
    target_rms: float | None = None,
    target_level: float | None = None,
    unit: str,
) -> dict[str, ChannelCalibration]:
    """Derive absolute per-channel calibration from this reference event.

    Exactly one known physical scalar is broadcast to every channel. The
    frame is not changed and no operation is added to its history.
    ``target_rms`` is a linear value in ``unit``. ``target_level`` is an
    amplitude level using ``20 * log10(target_rms / ref)`` and the default
    reference for ``unit``; for ``unit="Pa"`` that reference is ``2e-5 Pa``.
    """
    labels = self.labels
    if any(not label for label in labels) or len(set(labels)) != len(labels):
        raise ValueError("Calibration derivation requires unique non-empty channel labels")
    domain = ChannelCalibration(factor=1.0, unit=unit)
    factors = _derive_absolute_calibration_factors(
        self.rms,
        [channel.calibration.factor for channel in self.channels],
        target_rms=target_rms,
        target_level=target_level,
        ref=domain.ref,
    )
    return {
        label: ChannelCalibration(factor=factor, unit=domain.unit, ref=domain.ref)
        for label, factor in zip(labels, factors, strict=True)
    }

info()

Display comprehensive information about the ChannelFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - Duration - Number of samples - Channel labels

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples:

>>> import wandas as wd
>>> cf = wd.read("audio.wav")
>>> cf.info()
Channels: 2
Sampling rate: 44100 Hz
Duration: 1.0 s
Samples: 44100
Channel labels: ['ch0', 'ch1']
Source code in wandas/frames/channel.py
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
def info(self) -> None:
    """Display comprehensive information about the ChannelFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - Duration
    - Number of samples
    - Channel labels

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples:
        >>> import wandas as wd
        >>> cf = wd.read("audio.wav")
        >>> cf.info()
        Channels: 2
        Sampling rate: 44100 Hz
        Duration: 1.0 s
        Samples: 44100
        Channel labels: ['ch0', 'ch1']
    """
    print("ChannelFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  Duration: {self.duration:.1f} s")
    print(f"  Samples: {self.n_samples}")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()

mix(other, *, align='strict', snr_db=None)

Mix another signal lazily by array index.

The base frame owns output length, channels, metadata, labels, and source_time_offset. pad accepts only a shorter other signal; truncate accepts only a longer one.

Parameters:

Name Type Description Default
other ChannelFrame | NDArrayReal | Array

A ChannelFrame or one-/two-dimensional NumPy or Dask array. A single channel broadcasts across the base channels; otherwise channel counts must match.

required
align str

Length policy. "strict" requires equal sample counts, "pad" zero-pads a shorter input, and "truncate" cuts a longer input to the base length.

'strict'
snr_db float | None

Optional signal-to-noise ratio in decibels used to scale other before addition. None performs direct addition.

None

Returns:

Type Description
ChannelFrame

A new lazy ChannelFrame with the base frame's structure and metadata.

Raises:

Type Description
TypeError

If other or snr_db has an unsupported type.

ValueError

If sampling rates, dimensions, channel counts, lengths, or align do not satisfy the selected contract.

Notes

Source-time offsets describe provenance and do not shift array positions. Signals from different source-time regions can therefore be mixed directly.

Source code in wandas/frames/channel.py
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
@recipe_operation(
    "wandas.audio.mix",
    binding_patterns=_MIX_INPUT_PATTERNS,
    capture=_capture_channel_input("other"),
    handler=_mix_recipe,
)
def mix(
    self,
    other: "ChannelFrame | NDArrayReal | DaArray",
    *,
    align: str = "strict",
    snr_db: float | None = None,
) -> "ChannelFrame":
    """Mix another signal lazily by array index.

    The base frame owns output length, channels, metadata, labels, and
    ``source_time_offset``. ``pad`` accepts only a shorter other signal;
    ``truncate`` accepts only a longer one.

    Args:
        other: A ChannelFrame or one-/two-dimensional NumPy or Dask array. A
            single channel broadcasts across the base channels; otherwise channel
            counts must match.
        align: Length policy. ``"strict"`` requires equal sample counts,
            ``"pad"`` zero-pads a shorter input, and ``"truncate"`` cuts a longer
            input to the base length.
        snr_db: Optional signal-to-noise ratio in decibels used to scale ``other``
            before addition. ``None`` performs direct addition.

    Returns:
        A new lazy ChannelFrame with the base frame's structure and metadata.

    Raises:
        TypeError: If ``other`` or ``snr_db`` has an unsupported type.
        ValueError: If sampling rates, dimensions, channel counts, lengths, or
            ``align`` do not satisfy the selected contract.

    Notes:
        Source-time offsets describe provenance and do not shift array positions.
        Signals from different source-time regions can therefore be mixed directly.
    """
    if align not in {"strict", "pad", "truncate"}:
        raise ValueError("align must be 'strict', 'pad', or 'truncate'")
    if snr_db is not None and not isinstance(snr_db, int | float | np.number):
        raise TypeError("snr_db must be numeric or None")

    if isinstance(other, ChannelFrame):
        if self.sampling_rate != other.sampling_rate:
            raise ValueError(
                f"Sampling rate mismatch: {self.sampling_rate} Hz != {other.sampling_rate} Hz; resample first"
            )
        other_data = other._effective_data
    elif isinstance(other, np.ndarray):
        if other.ndim not in {1, 2}:
            raise ValueError("mix array input must be 1-D or channel-first 2-D")
        other_data = _da_from_array(other[None, :] if other.ndim == 1 else other, chunks=(1, -1))
    elif isinstance(other, DaArray):
        if other.ndim not in {1, 2}:
            raise ValueError("mix array input must be 1-D or channel-first 2-D")
        other_data = other[None, :] if other.ndim == 1 else other
    else:
        raise TypeError("mix requires a ChannelFrame, NumPy array, or Dask array; scalars are invalid")

    other_channels, other_samples = other_data.shape
    if other_channels not in {1, self.n_channels}:
        raise ValueError(
            f"mix channel count must match the base or be mono: base={self.n_channels}, other={other_channels}"
        )
    if align == "strict" and other_samples != self.n_samples:
        raise ValueError(f"strict mix requires equal lengths: base={self.n_samples}, other={other_samples}")
    if align == "pad":
        if other_samples >= self.n_samples:
            raise ValueError("pad mix requires the other signal to be shorter than the base")
        padding = da.zeros((other_channels, self.n_samples - other_samples), dtype=other_data.dtype)
        other_data = concatenate((other_data, padding), axis=1)
    if align == "truncate":
        if other_samples <= self.n_samples:
            raise ValueError("truncate mix requires the other signal to be longer than the base")
        other_data = other_data[:, : self.n_samples]

    if snr_db is None:
        result_data = self._effective_data + other_data
    else:
        from wandas.processing import create_operation

        operation = create_operation("add_with_snr", self.sampling_rate, snr=float(snr_db))
        result_data = operation.process(self._effective_data, other_data)

    return self._create_new_instance(
        data=result_data,
        channel_metadata=self._metadata_after_analysis(),
        lineage=self._required_semantic_lineage(),
    )

plot(plot_type='waveform', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, **kwargs)

Plot the frame data.

Parameters:

Name Type Description Default
plot_type str

Type of plot. Default is "waveform".

'waveform'
ax Axes | None

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot. If None, uses the frame label.

None
overlay bool

Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False).

False
xlabel str | None

Label for the x-axis. If None, uses default based on plot type.

None
ylabel str | None

Label for the y-axis. If None, uses default based on plot type.

None
alpha float

Transparency level for the plot lines (0.0 to 1.0).

1.0
xlim tuple[float, float] | None

Limits for the x-axis as (min, max) tuple.

None
ylim tuple[float, float] | None

Limits for the y-axis as (min, max) tuple.

None
**kwargs Any

Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle). These are passed to the underlying matplotlib plot functions.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> import wandas as wd
>>> cf = wd.read("audio.wav")
>>> # Basic plot
>>> cf.plot()
>>> # Overlay all channels
>>> cf.plot(overlay=True, alpha=0.7)
>>> # Custom styling
>>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
Source code in wandas/frames/channel.py
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
def plot(
    self,
    plot_type: str = "waveform",
    ax: "Axes | None" = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    **kwargs: Any,
) -> "Axes | Iterator[Axes]":
    """Plot the frame data.

    Args:
        plot_type: Type of plot. Default is "waveform".
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot. If None, uses the frame label.
        overlay: Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel: Label for the x-axis. If None, uses default based on plot type.
        ylabel: Label for the y-axis. If None, uses default based on plot type.
        alpha: Transparency level for the plot lines (0.0 to 1.0).
        xlim: Limits for the x-axis as (min, max) tuple.
        ylim: Limits for the y-axis as (min, max) tuple.
        **kwargs: Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).
            These are passed to the underlying matplotlib plot functions.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> import wandas as wd
        >>> cf = wd.read("audio.wav")
        >>> # Basic plot
        >>> cf.plot()
        >>> # Overlay all channels
        >>> cf.plot(overlay=True, alpha=0.7)
        >>> # Custom styling
        >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
    """
    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    from ..visualization.plotting import create_operation

    plot_strategy = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax

rms_plot(ax=None, title=None, overlay=True, Aw=False, **kwargs)

Plot a windowed RMS amplitude level in decibels.

rms_plot() calls :meth:rms_trend with dB=True. It is not a plot of the linear :attr:rms scalar. Values use 20 * log10(window_rms / channel_ref); Aw=True applies the implemented digital A-weighting filter before the RMS calculation. The implementation has not been validated as a conforming sound-level meter.

Parameters:

Name Type Description Default
ax Axes | None

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot.

None
overlay bool

Whether to overlay the plot on the existing axis.

True
Aw bool

Apply the implemented A-frequency-weighting filter.

False
**kwargs Any

Additional arguments passed to the plot() method. Accepts the same arguments as plot() including xlabel, ylabel, alpha, xlim, ylim, and matplotlib Line2D parameters.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> cf = wd.read("audio.wav")
>>> # Basic RMS plot
>>> cf.rms_plot()
>>> # With A-weighting
>>> cf.rms_plot(Aw=True)
>>> # Custom styling
>>> cf.rms_plot(ylabel="RMS level [dB re channel reference]", alpha=0.8, color="blue")
Source code in wandas/frames/channel.py
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
def rms_plot(
    self,
    ax: "Axes | None" = None,
    title: str | None = None,
    overlay: bool = True,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> "Axes | Iterator[Axes]":
    """Plot a windowed RMS amplitude level in decibels.

    ``rms_plot()`` calls :meth:`rms_trend` with ``dB=True``. It is not a
    plot of the linear :attr:`rms` scalar. Values use
    ``20 * log10(window_rms / channel_ref)``; ``Aw=True`` applies the
    implemented digital A-weighting filter before the RMS calculation.
    The implementation has not been validated as a conforming sound-level
    meter.

    Args:
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot.
        overlay: Whether to overlay the plot on the existing axis.
        Aw: Apply the implemented A-frequency-weighting filter.
        **kwargs: Additional arguments passed to the plot() method.
            Accepts the same arguments as plot() including xlabel, ylabel,
            alpha, xlim, ylim, and matplotlib Line2D parameters.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> cf = wd.read("audio.wav")
        >>> # Basic RMS plot
        >>> cf.rms_plot()
        >>> # With A-weighting
        >>> cf.rms_plot(Aw=True)
        >>> # Custom styling
        >>> cf.rms_plot(ylabel="RMS level [dB re channel reference]", alpha=0.8, color="blue")
    """
    kwargs = kwargs or {}
    weighting = "A-weighted RMS level" if Aw else "RMS level"
    explicit_ylabel = "ylabel" in kwargs
    ylabel = kwargs.pop("ylabel", weighting)
    kwargs["_append_channel_units"] = not explicit_ylabel
    rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
    return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)

describe(normalize=True, is_close=True, *, fmin=0, fmax=None, cmap='jet', vmin=None, vmax=None, xlim=None, ylim=None, Aw=False, waveform=None, spectral=None, image_save=None, **kwargs)

Display visual and audio representation of the frame.

This method creates a comprehensive visualization with three plots: 1. Time-domain waveform (top) 2. Spectrogram (bottom-left) 3. Frequency spectrum via Welch method (bottom-right)

Parameters:

Name Type Description Default
normalize bool

Whether to normalize the audio data for playback. Default: True

True
is_close bool

Whether to close the figure after displaying. Default: True

True
fmin float

Minimum frequency to display in the spectrogram (Hz). Default: 0

0
fmax float | None

Maximum frequency to display in the spectrogram (Hz). Default: Nyquist frequency (sampling_rate / 2)

None
cmap str

Colormap for the spectrogram. Default: 'jet'

'jet'
vmin float | None

Minimum value for spectrogram color scale (dB). Auto-calculated if None.

None
vmax float | None

Maximum value for spectrogram color scale (dB). Auto-calculated if None.

None
xlim tuple[float, float] | None

Time axis limits (seconds) for all time-based plots. Format: (start_time, end_time)

None
ylim tuple[float, float] | None

Frequency axis limits (Hz) for frequency-based plots. Format: (min_freq, max_freq)

None
Aw bool

Apply A-weighting to the frequency analysis. Default: False

False
waveform dict[str, Any] | None

Additional configuration dict for waveform subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
spectral dict[str, Any] | None

Additional configuration dict for spectral subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
image_save str | Path | None

Path to save the figure as an image file. If provided, the figure will be saved before closing. File format is determined from the extension (e.g., '.png', '.jpg', '.pdf'). For multi-channel frames, the channel index is appended to the filename stem (e.g., 'output_0.png', 'output_1.png'). Default: None.

None
**kwargs Any

Deprecated parameters for backward compatibility only. - axis_config: Old configuration format (use waveform/spectral instead) - cbar_config: Old colorbar configuration (use vmin/vmax instead)

{}

Returns:

Type Description
list[Figure] | None

None (default). When is_close=False, returns a list of matplotlib Figure objects created for each channel. The list length equals the number of channels in the frame.

Examples:

>>> cf = wd.read("audio.wav")
>>> # Basic usage
>>> cf.describe()
>>>
>>> # Custom frequency range
>>> cf.describe(fmin=100, fmax=5000)
>>>
>>> # Custom color scale
>>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
>>>
>>> # A-weighted analysis
>>> cf.describe(Aw=True)
>>>
>>> # Custom time range
>>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
>>>
>>> # Custom waveform subplot settings
>>> cf.describe(waveform={"ylabel": "Custom Label"})
>>>
>>> # Save the figure to a file
>>> cf.describe(image_save="output.png")
>>>
>>> # Get Figure objects for further manipulation (is_close=False)
>>> figures = cf.describe(is_close=False)
>>> fig = figures[0]
>>> fig.savefig("custom_output.png")  # Custom save with modifications
Source code in wandas/frames/channel.py
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
def describe(
    self,
    normalize: bool = True,
    is_close: bool = True,
    *,
    fmin: float = 0,
    fmax: float | None = None,
    cmap: str = "jet",
    vmin: float | None = None,
    vmax: float | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    waveform: dict[str, Any] | None = None,
    spectral: dict[str, Any] | None = None,
    image_save: str | Path | None = None,
    **kwargs: Any,
) -> "list[Figure] | None":
    """Display visual and audio representation of the frame.

    This method creates a comprehensive visualization with three plots:
    1. Time-domain waveform (top)
    2. Spectrogram (bottom-left)
    3. Frequency spectrum via Welch method (bottom-right)

    Args:
        normalize: Whether to normalize the audio data for playback.
            Default: True
        is_close: Whether to close the figure after displaying.
            Default: True
        fmin: Minimum frequency to display in the spectrogram (Hz).
            Default: 0
        fmax: Maximum frequency to display in the spectrogram (Hz).
            Default: Nyquist frequency (sampling_rate / 2)
        cmap: Colormap for the spectrogram.
            Default: 'jet'
        vmin: Minimum value for spectrogram color scale (dB).
            Auto-calculated if None.
        vmax: Maximum value for spectrogram color scale (dB).
            Auto-calculated if None.
        xlim: Time axis limits (seconds) for all time-based plots.
            Format: (start_time, end_time)
        ylim: Frequency axis limits (Hz) for frequency-based plots.
            Format: (min_freq, max_freq)
        Aw: Apply A-weighting to the frequency analysis.
            Default: False
        waveform: Additional configuration dict for waveform subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        spectral: Additional configuration dict for spectral subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        image_save: Path to save the figure as an image file. If provided,
            the figure will be saved before closing. File format is determined
            from the extension (e.g., '.png', '.jpg', '.pdf'). For multi-channel
            frames, the channel index is appended to the filename stem
            (e.g., 'output_0.png', 'output_1.png'). Default: None.
        **kwargs: Deprecated parameters for backward compatibility only.
            - axis_config: Old configuration format (use waveform/spectral instead)
            - cbar_config: Old colorbar configuration (use vmin/vmax instead)

    Returns:
        None (default). When `is_close=False`, returns a list of matplotlib Figure
            objects created for each channel. The list length equals the number of
            channels in the frame.

    Examples:
        >>> cf = wd.read("audio.wav")
        >>> # Basic usage
        >>> cf.describe()
        >>>
        >>> # Custom frequency range
        >>> cf.describe(fmin=100, fmax=5000)
        >>>
        >>> # Custom color scale
        >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
        >>>
        >>> # A-weighted analysis
        >>> cf.describe(Aw=True)
        >>>
        >>> # Custom time range
        >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
        >>>
        >>> # Custom waveform subplot settings
        >>> cf.describe(waveform={"ylabel": "Custom Label"})
        >>>
        >>> # Save the figure to a file
        >>> cf.describe(image_save="output.png")
        >>>
        >>> # Get Figure objects for further manipulation (is_close=False)
        >>> figures = cf.describe(is_close=False)
        >>> fig = figures[0]
        >>> fig.savefig("custom_output.png")  # Custom save with modifications
    """
    from wandas.visualization.describe import describe_frame

    return describe_frame(
        self,
        normalize=normalize,
        is_close=is_close,
        fmin=fmin,
        fmax=fmax,
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
        xlim=xlim,
        ylim=ylim,
        Aw=Aw,
        waveform=waveform,
        spectral=spectral,
        image_save=image_save,
        **kwargs,
    )

from_numpy(data, sampling_rate, label=None, metadata=None, ch_labels=None, ch_units=None) classmethod

Create a ChannelFrame from a NumPy array.

Parameters:

Name Type Description Default
data NDArrayReal

NumPy array containing channel data.

required
sampling_rate float

The sampling rate in Hz.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
ch_labels list[str] | None

Labels for each channel.

None
ch_units list[str] | str | None

Units for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the NumPy data.

Source code in wandas/frames/channel.py
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
@classmethod
def from_numpy(
    cls,
    data: NDArrayReal,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    ch_labels: list[str] | None = None,
    ch_units: list[str] | str | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    Args:
        data: NumPy array containing channel data.
        sampling_rate: The sampling rate in Hz.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        ch_labels: Labels for each channel.
        ch_units: Units for each channel.

    Returns:
        A new ChannelFrame containing the NumPy data.
    """
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim > 2:
        raise ValueError(f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}")

    # Convert NumPy array to dask array. Use channel-wise chunks so
    # the 0th axis (channels) is chunked per-channel and the sample
    # axis remains un-chunked by default.
    dask_data = _da_from_array(data, chunks=(1, -1))
    cf = cls(
        data=dask_data,
        sampling_rate=sampling_rate,
        label=label or "numpy_data",
        metadata=metadata,
    )
    if ch_labels is not None:
        cf._set_channel_labels(ch_labels)
    if ch_units is not None:
        if isinstance(ch_units, str):
            ch_units = [ch_units] * cf.n_channels
        cf._set_channel_units(ch_units)

    return cf

from_ndarray(array, sampling_rate, labels=None, unit=None, frame_label=None, metadata=None) classmethod

Create a ChannelFrame from a NumPy array.

This method is deprecated. Use from_numpy instead.

Parameters:

Name Type Description Default
array NDArrayReal

Signal data. Each row corresponds to a channel.

required
sampling_rate float

Sampling rate (Hz).

required
labels list[str] | None

Labels for each channel.

None
unit list[str] | str | None

Unit of the signal.

None
frame_label str | None

Label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data.

Source code in wandas/frames/channel.py
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
@classmethod
def from_ndarray(
    cls,
    array: NDArrayReal,
    sampling_rate: float,
    labels: list[str] | None = None,
    unit: list[str] | str | None = None,
    frame_label: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    This method is deprecated. Use from_numpy instead.

    Args:
        array: Signal data. Each row corresponds to a channel.
        sampling_rate: Sampling rate (Hz).
        labels: Labels for each channel.
        unit: Unit of the signal.
        frame_label: Label for the frame.
        metadata: Optional metadata dictionary.

    Returns:
        A new ChannelFrame containing the data.
    """
    warnings.warn(
        "from_ndarray is deprecated. Use from_numpy instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    return cls.from_numpy(
        data=array,
        sampling_rate=sampling_rate,
        label=frame_label,
        metadata=metadata,
        ch_labels=labels,
        ch_units=unit,
    )

from_file(path, channel=None, start=None, end=None, ch_labels=None, time_column=0, delimiter=',', header=0, file_type=None, source_name=None, timeout=10.0) classmethod

Create a ChannelFrame from an audio file or URL.

Note

The chunk_size parameter has been removed. ChannelFrame uses channel-wise chunking by default (chunks=(1, -1)). Use .rechunk(...) on the returned frame for custom sample-axis chunking.

Audio sample decoding is deferred through the returned Dask array. CSV metadata inspection synchronously parses the complete table to determine shape and sampling rate; the sample table is parsed again when the Dask data is computed.

Parameters:

Name Type Description Default
path str | Path | bytes | bytearray | memoryview | BinaryIO

Path to the audio file, in-memory bytes/stream, or an HTTP/HTTPS URL. When a URL is given it is streamed into a temporary file before processing, subject to the maximum download size enforced by wandas.io.readers.MAX_URL_DOWNLOAD_BYTES. Oversized URL downloads fail before loading completes. The file extension is inferred from the URL path; supply file_type explicitly when the URL has no recognisable extension. If you need to allow larger URL downloads, increase wandas.io.readers.MAX_URL_DOWNLOAD_BYTES before calling this method.

required
channel int | list[int] | None

Channel(s) to load. None loads all channels.

None
start float | None

Start time in seconds.

None
end float | None

End time in seconds.

None
ch_labels list[str] | None

Labels for each channel.

None
time_column int | str

For CSV files, index or name of the time column. Default is 0 (first column).

0
delimiter str

For CSV files, delimiter character. Default is ",".

','
header int | None

For CSV files, row number to use as header. Default is 0 (first row). Set to None if no header.

0
file_type str | None

File extension for in-memory data or URLs without a recognisable extension (e.g. ".wav", ".csv").

None
source_name str | None

Optional source name for in-memory data. Used in metadata.

None
timeout float

Timeout in seconds for HTTP/HTTPS URL downloads. Default is 10.0 seconds. Has no effect for local files or in-memory data.

10.0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the loaded data. SoundFile-backed audio channels use the explicit linear unit FS and reference 1. CSV channels remain generic.

Raises:

Type Description
ValueError

If channel specification is invalid or file cannot be read. Error message includes absolute path, current directory, and troubleshooting suggestions.

Examples:

>>> import wandas as wd
>>> # Load WAV file as full-scale float64 audio
>>> cf = wd.read("audio.wav")
>>> # Load specific channels
>>> cf = wd.read("audio.wav", channel=[0, 2])
>>> # Load CSV file
>>> cf = wd.read("data.csv", time_column=0, delimiter=",", header=0)
>>> # Load from a URL
>>> cf = wd.read("https://example.com/audio.wav")
Source code in wandas/frames/channel.py
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
@classmethod
def from_file(
    cls,
    path: str | Path | bytes | bytearray | memoryview | BinaryIO,
    channel: int | list[int] | None = None,
    start: float | None = None,
    end: float | None = None,
    # NOTE: chunk_size removed — chunking is handled internally as
    # channel-wise (1, -1). This simplifies the API and prevents
    # users from accidentally breaking channel-wise parallelism.
    ch_labels: list[str] | None = None,
    # CSV-specific parameters
    time_column: int | str = 0,
    delimiter: str = ",",
    header: int | None = 0,
    file_type: str | None = None,
    source_name: str | None = None,
    timeout: float = 10.0,
) -> "ChannelFrame":
    """Create a ChannelFrame from an audio file or URL.

    Note:
        The `chunk_size` parameter has been removed. ChannelFrame uses
        channel-wise chunking by default (chunks=(1, -1)). Use `.rechunk(...)`
        on the returned frame for custom sample-axis chunking.

        Audio sample decoding is deferred through the returned Dask array.
        CSV metadata inspection synchronously parses the complete table to
        determine shape and sampling rate; the sample table is parsed again
        when the Dask data is computed.

    Args:
        path: Path to the audio file, in-memory bytes/stream, or an HTTP/HTTPS
            URL. When a URL is given it is streamed into a temporary file
            before processing, subject to the maximum download size
            enforced by `wandas.io.readers.MAX_URL_DOWNLOAD_BYTES`.
            Oversized URL downloads fail before loading completes. The file
            extension is inferred from the URL path; supply `file_type`
            explicitly when the URL has no recognisable extension. If you
            need to allow larger URL downloads, increase
            `wandas.io.readers.MAX_URL_DOWNLOAD_BYTES` before calling this
            method.
        channel: Channel(s) to load. None loads all channels.
        start: Start time in seconds.
        end: End time in seconds.
        ch_labels: Labels for each channel.
        time_column: For CSV files, index or name of the time column.
            Default is 0 (first column).
        delimiter: For CSV files, delimiter character. Default is ",".
        header: For CSV files, row number to use as header.
            Default is 0 (first row). Set to None if no header.
        file_type: File extension for in-memory data or URLs without a
            recognisable extension (e.g. ".wav", ".csv").
        source_name: Optional source name for in-memory data. Used in metadata.
        timeout: Timeout in seconds for HTTP/HTTPS URL downloads. Default is
            10.0 seconds. Has no effect for local files or in-memory data.

    Returns:
        A new ChannelFrame containing the loaded data. SoundFile-backed
            audio channels use the explicit linear unit ``FS`` and
            reference 1. CSV channels remain generic.

    Raises:
        ValueError: If channel specification is invalid or file cannot be read.
            Error message includes absolute path, current directory, and
            troubleshooting suggestions.

    Examples:
        >>> import wandas as wd
        >>> # Load WAV file as full-scale float64 audio
        >>> cf = wd.read("audio.wav")
        >>> # Load specific channels
        >>> cf = wd.read("audio.wav", channel=[0, 2])
        >>> # Load CSV file
        >>> cf = wd.read("data.csv", time_column=0, delimiter=",", header=0)
        >>> # Load from a URL
        >>> cf = wd.read("https://example.com/audio.wav")
    """
    from .channel import ChannelFrame

    download_owner: DownloadedTemporaryFile | None = None
    downloaded_from_url = False

    # Validate optional CSV dependencies before starting a remote download.
    if isinstance(path, str) and path.lower().startswith(("http://", "https://")):
        url_file_type = file_type
        if url_file_type is None:
            from pathlib import PurePosixPath
            from urllib.parse import urlparse

            url_file_type = PurePosixPath(urlparse(path).path).suffix.lower() or None
        if url_file_type is not None and url_file_type.lower().lstrip(".") == "csv":
            require_pandas("CSV file reading")

        path, download_owner, file_type, source_name = _download_url(path, file_type, source_name, timeout)
        downloaded_from_url = True

    try:
        source_obj, path_obj, reader, normalized_file_type = _resolve_source(path, file_type)
    except Exception:
        if download_owner is not None:
            download_owner.cleanup()
        raise

    # Build kwargs for reader
    reader_kwargs: dict[str, Any] = {}
    if (path_obj is not None and path_obj.suffix.lower() == ".csv") or (normalized_file_type == ".csv"):
        reader_kwargs["time_column"] = time_column
        reader_kwargs["delimiter"] = delimiter
        reader_kwargs["header"] = header

    try:
        info = reader.get_file_info(source_obj, **reader_kwargs)
    except Exception:
        if download_owner is not None:
            download_owner.cleanup()
        raise
    sr = info["samplerate"]
    n_channels = info["channels"]
    n_frames = info["frames"]
    ch_labels = ch_labels or info.get("ch_labels", None)
    source_time_start = float(info.get("time_start", 0.0))

    logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

    # Channel selection processing
    try:
        channels_to_load = _resolve_channels(channel, n_channels)
    except Exception:
        if download_owner is not None:
            download_owner.cleanup()
        raise

    # Index calculation
    start_idx = 0 if start is None else max(0, int(start * sr))
    end_idx = n_frames if end is None else min(n_frames, int(end * sr))
    frames_to_read = end_idx - start_idx

    logger.debug(
        f"Setting up lazy load from file={path!r}, frames={frames_to_read}, "
        f"start_idx={start_idx}, end_idx={end_idx}"
    )

    # Settings for lazy loading
    expected_shape = (len(channels_to_load), frames_to_read)

    captured_download_owner = download_owner

    # Define the loading function using the file reader
    def _load_audio() -> NDArrayReal:
        """Read the selected file segment when Dask executes the delayed task."""
        logger.debug(">>> EXECUTING DELAYED LOAD <<<")
        # Log the temporary download path so this closure keeps ownership of
        # the streamed file until the delayed read completes.
        if captured_download_owner is not None:
            logger.debug("Reading from streamed temporary download %s", captured_download_owner.path)
        # Use the reader to get audio data with parameters
        out = reader.get_data(
            source_obj,
            channels_to_load,
            start_idx,
            frames_to_read,
            **reader_kwargs,
        )
        if not isinstance(out, np.ndarray):
            raise ValueError("Unexpected data type after reading file")
        if out.shape != expected_shape:
            raise ValueError(
                "Reader returned an unexpected channel-first shape\n"
                f"  Got: {out.shape}\n"
                f"  Expected: {expected_shape}"
            )
        if not np.issubdtype(out.dtype, np.number) or np.issubdtype(out.dtype, np.complexfloating):
            raise TypeError("Readers must return a real channel-first numeric array")
        return out.astype(np.float64, copy=False)

    logger.debug(f"Creating delayed dask task with expected shape: {expected_shape}")

    # Create delayed operation
    try:
        delayed_data = dask_delayed(_load_audio)()
        logger.debug("Wrapping delayed function in dask array")

        # Create dask array from delayed computation and ensure channel-wise
        # chunks. The sample axis (1) uses -1 by default to avoid forcing
        # a sample chunk length here.
        dask_array = da_from_delayed(delayed_data, shape=expected_shape, dtype=np.float64)

        # Ensure channel-wise chunks
        dask_array = dask_array.rechunk((1, -1))
    except Exception:
        if download_owner is not None:
            download_owner.cleanup()
        raise

    logger.debug("ChannelFrame setup complete - actual file reading will occur on compute()")

    if source_name is not None:
        try:
            if source_name.lower().startswith(("http://", "https://")):
                from pathlib import PurePosixPath
                from urllib.parse import urlparse

                frame_label = PurePosixPath(urlparse(source_name).path).stem
            else:
                frame_label = Path(source_name).stem
        except (TypeError, ValueError, OSError):
            logger.debug(
                "Using raw source_name as frame label because Path(source_name) failed; source_name=%r",
                source_name,
            )
            frame_label = source_name
    elif path_obj is not None:
        frame_label = path_obj.stem
    else:
        frame_label = None
    source_file: str | None = None
    if downloaded_from_url and source_name is not None:
        source_file = source_name
    elif path_obj is not None:
        source_file = str(path_obj.resolve())
    elif source_name is not None:
        source_file = source_name

    try:
        channel_metadata = None
        if info.get("unit") == "FS":
            channel_metadata = [
                ChannelMetadata(
                    label=f"ch{index}",
                    calibration=ChannelCalibration(unit="FS", ref=1.0),
                )
                for index, _ in enumerate(channels_to_load)
            ]
        cf = ChannelFrame(
            data=dask_array,
            sampling_rate=sr,
            label=frame_label,
            metadata={"_source_file": source_file} if source_file is not None else None,
            channel_metadata=channel_metadata,
            source_time_offset=source_time_start + start_idx / sr,
        )
        if ch_labels is not None:
            cf._set_channel_labels(ch_labels)
    except Exception:
        if download_owner is not None:
            download_owner.cleanup()
        raise
    return cf

read_wav(filename, labels=None) classmethod

Utility method to read a WAV file.

Parameters:

Name Type Description Default
filename str | Path | bytes | bytearray | memoryview | BinaryIO

Path to the WAV file or in-memory bytes/stream.

required
labels list[str] | None

Labels to set for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Source code in wandas/frames/channel.py
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
@classmethod
def read_wav(
    cls,
    filename: str | Path | bytes | bytearray | memoryview | BinaryIO,
    labels: list[str] | None = None,
) -> "ChannelFrame":
    """Utility method to read a WAV file.

    Args:
        filename: Path to the WAV file or in-memory bytes/stream.
        labels: Labels to set for each channel.

    Returns:
        A new ChannelFrame containing the data (lazy loading).
    """
    from .channel import ChannelFrame

    is_in_memory = isinstance(filename, (bytes, bytearray, memoryview)) or _is_file_like(filename)
    source_name: str | None = None
    if is_in_memory and _is_file_like(filename):
        source_name = getattr(filename, "name", None)
    cf = ChannelFrame.from_file(
        filename,
        ch_labels=labels,
        file_type=".wav" if is_in_memory else None,
        source_name=source_name,
    )
    return cf

read_csv(filename, time_column=0, labels=None, delimiter=',', header=0) classmethod

Utility method to read a CSV file.

Parameters:

Name Type Description Default
filename str

Path to the CSV file.

required
time_column int | str

Index or name of the time column.

0
labels list[str] | None

Labels to set for each channel.

None
delimiter str

Delimiter character.

','
header int | None

Row number to use as header.

0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing Dask-backed sample data. CSV metadata inspection occurs synchronously before the Frame is returned.

Examples:

>>> # Read CSV with default settings
>>> cf = wd.read("data.csv")
>>> # Read CSV with custom delimiter
>>> cf = wd.read("data.csv", delimiter=";")
>>> # Read CSV without header
>>> cf = wd.read("data.csv", header=None)
Source code in wandas/frames/channel.py
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
@classmethod
def read_csv(
    cls,
    filename: str,
    time_column: int | str = 0,
    labels: list[str] | None = None,
    delimiter: str = ",",
    header: int | None = 0,
) -> "ChannelFrame":
    """Utility method to read a CSV file.

    Args:
        filename: Path to the CSV file.
        time_column: Index or name of the time column.
        labels: Labels to set for each channel.
        delimiter: Delimiter character.
        header: Row number to use as header.

    Returns:
        A new ChannelFrame containing Dask-backed sample data. CSV metadata
            inspection occurs synchronously before the Frame is returned.

    Examples:
        >>> # Read CSV with default settings
        >>> cf = wd.read("data.csv")
        >>> # Read CSV with custom delimiter
        >>> cf = wd.read("data.csv", delimiter=";")
        >>> # Read CSV without header
        >>> cf = wd.read("data.csv", header=None)
    """
    from .channel import ChannelFrame

    cf = ChannelFrame.from_file(
        filename,
        ch_labels=labels,
        time_column=time_column,
        delimiter=delimiter,
        header=header,
    )
    return cf

to_wav(path, format=None)

Save the audio data to a WAV file.

Parameters:

Name Type Description Default
path str | Path

Path to save the file.

required
format str | None

File format. If None, determined from file extension.

None
Source code in wandas/frames/channel.py
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
def to_wav(self, path: str | Path, format: str | None = None) -> None:
    """Save the audio data to a WAV file.

    Args:
        path: Path to save the file.
        format: File format. If None, determined from file extension.
    """
    from wandas.io.wav_io import write_wav

    write_wav(str(path), self, format=format)

load(path) classmethod

Load a ChannelFrame from a WDF (Wandas Data File) file.

This loads data saved with the save() method, preserving all channel data, metadata, labels, and units.

Parameters:

Name Type Description Default
path str | Path

Path to the WDF file

required

Returns:

Type Description
ChannelFrame

A new ChannelFrame with all data and metadata loaded

Raises:

Type Description
FileNotFoundError

If the file doesn't exist

Examples:

>>> import wandas as wd
>>> cf = wd.load("audio_analysis.wdf")
Source code in wandas/frames/channel.py
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
@classmethod
def load(cls, path: str | Path) -> "ChannelFrame":
    """Load a ChannelFrame from a WDF (Wandas Data File) file.

    This loads data saved with the save() method, preserving all channel data,
    metadata, labels, and units.

    Args:
        path: Path to the WDF file

    Returns:
        A new ChannelFrame with all data and metadata loaded

    Raises:
        FileNotFoundError: If the file doesn't exist

    Examples:
        >>> import wandas as wd
        >>> cf = wd.load("audio_analysis.wdf")
    """
    from ..io.wdf_io import load as wdf_load

    loaded = wdf_load(path)
    if not isinstance(loaded, ChannelFrame):
        raise TypeError(
            "ChannelFrame.load() received a different typed WDF Frame\n"
            f"  Got: {type(loaded).__name__}\n"
            "  Expected: ChannelFrame\n"
            "Use wd.load() when the stored Frame type is not known in advance."
        )
    return loaded

add_channel(data, label=None, align='strict', suffix_on_dup=None, source_time_offset=None)

Add a new channel to the frame.

Parameters:

Name Type Description Default
data ndarray[Any, Any] | Array

NumPy or Dask data for exactly one channel. The accepted shapes are (samples,) and (1, samples).

required
label str | None

Label for the new channel. If None, generates a default label.

None
align str

How to handle length mismatches: - "strict": Raise error if lengths don't match - "pad": Pad shorter data with zeros - "truncate": Truncate longer data to match

'strict'
suffix_on_dup str | None

Suffix to add to duplicate labels. If None, raises error.

None
source_time_offset float | Sequence[float] | NDArrayReal | None

Offset in seconds for the new channel. Accepts a finite real scalar or a one-item 1-D sequence/NumPy array. If None, the new channel uses 0.0. Accepted forms are normalized to a built-in float before execution and Recipe capture.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame.

Raises:

Type Description
ValueError

If data length doesn't match and align="strict", or if label is duplicate and suffix_on_dup is None.

TypeError

If data is not a NumPy or Dask array. Pass another ChannelFrame to :meth:concat_frame instead.

Examples:

>>> cf = wd.read("audio.wav")
>>> # Add a numpy array as a new channel
>>> new_data = np.sin(2 * np.pi * 440 * cf.time)
>>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
>>> # Concatenate another ChannelFrame's channels
>>> cf2 = wd.read("audio2.wav")
>>> cf_combined = cf.concat_frame(cf2)
Source code in wandas/frames/channel.py
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
@_normalize_add_channel_call
@recipe_operation(
    "wandas.channel.add_channel",
    version=2,
    bindings=_ADD_CHANNEL_BINDINGS,
    capture=_capture_add_channel,
    handler=_add_channel_recipe,
    validate_params=_validate_add_channel_recipe,
)
def add_channel(
    self,
    data: "np.ndarray[Any, Any] | DaArray",
    label: str | None = None,
    align: str = "strict",
    suffix_on_dup: str | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal | None = None,
) -> "ChannelFrame":
    """Add a new channel to the frame.

    Args:
        data: NumPy or Dask data for exactly one channel. The accepted shapes
            are ``(samples,)`` and ``(1, samples)``.
        label: Label for the new channel. If None, generates a default label.
        align: How to handle length mismatches:
            - "strict": Raise error if lengths don't match
            - "pad": Pad shorter data with zeros
            - "truncate": Truncate longer data to match
        suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
        source_time_offset: Offset in seconds for the new channel. Accepts
            a finite real scalar or a one-item 1-D sequence/NumPy array.
            If None, the new channel uses 0.0. Accepted forms are normalized
            to a built-in float before execution and Recipe capture.

    Returns:
        A new ChannelFrame.

    Raises:
        ValueError: If data length doesn't match and align="strict",
            or if label is duplicate and suffix_on_dup is None.
        TypeError: If data is not a NumPy or Dask array. Pass another
            ChannelFrame to :meth:`concat_frame` instead.

    Examples:
        >>> cf = wd.read("audio.wav")
        >>> # Add a numpy array as a new channel
        >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
        >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
        >>> # Concatenate another ChannelFrame's channels
        >>> cf2 = wd.read("audio2.wav")
        >>> cf_combined = cf.concat_frame(cf2)
    """
    normalized_params = _normalize_channel_operation_params(
        {
            "label": label,
            "align": align,
            "suffix_on_dup": suffix_on_dup,
            "source_time_offset": source_time_offset,
        },
        label_name="label",
        allow_source_time_offset=True,
    )
    source_time_offset = cast(float, normalized_params["source_time_offset"])
    if isinstance(data, ChannelFrame):
        raise TypeError(
            "add_channel() no longer accepts ChannelFrame input; use concat_frame(other, label_prefix=...) instead"
        )
    if isinstance(data, np.ndarray):
        if data.ndim == 1:
            data = data[None, :]
        elif data.ndim != 2 or data.shape[0] != 1:
            raise ValueError("Raw add_channel input must be 1-D or shaped (1, samples)")
        arr = _da_from_array(data, chunks=(1, -1))
    elif isinstance(data, DaArray):
        if data.ndim == 1:
            arr = data[None, :]
        elif data.ndim == 2 and data.shape[0] == 1:
            arr = data
        else:
            raise ValueError("Raw add_channel input must be 1-D or shaped (1, samples)")
    else:
        raise TypeError("add_channel() data must be a NumPy array or Dask array")
    arr = _align_to_length(arr, self.n_samples, align, arr.shape[1])
    labels = self.labels
    new_label = label or f"ch{len(labels)}"
    if new_label in labels:
        if suffix_on_dup:
            new_label += suffix_on_dup
        else:
            raise ValueError(
                f"Duplicate channel label\n"
                f"  Label: '{new_label}'\n"
                f"  Existing labels: {labels}\n"
                f"Use suffix_on_dup parameter to automatically "
                f"rename duplicates."
            )
    new_data = concatenate([self._data, arr], axis=0)

    new_ids = [*self._channel_ids, self._next_channel_id()]
    new_chmeta = [*self._borrowed_channel_metadata_descriptors(), ChannelMetadata(label=new_label)]
    new_channel_offsets = np.asarray([source_time_offset], dtype=float)
    new_offsets = np.concatenate([self.source_time_offset, new_channel_offsets])
    return self._finalize_channel_update(
        new_data,
        new_chmeta,
        new_ids,
        new_offsets,
        lineage=self._required_semantic_lineage(),
    )

concat_frame(other, label_prefix=None, align='strict', suffix_on_dup=None)

Concatenate all channels from another frame along the channel axis.

The result preserves other channel metadata, calibration, and source-time offsets. Neither input frame is changed.

Parameters:

Name Type Description Default
other ChannelFrame

ChannelFrame whose channels are appended in their current order.

required
label_prefix str | None

Optional prefix for appended labels, producing "{label_prefix}_{original_label}".

None
align str

"strict" rejects sample-length differences; "pad" and "truncate" align the appended data to this frame's length.

'strict'
suffix_on_dup str | None

Suffix for duplicate labels. If None, duplicates raise.

None

Returns:

Type Description
ChannelFrame

A new lazy ChannelFrame containing both channel collections.

Raises:

Type Description
TypeError

If other is not a ChannelFrame.

ValueError

If sampling rates, lengths, or labels are incompatible.

Source code in wandas/frames/channel.py
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
@recipe_operation(
    "wandas.channel.concat_frame",
    bindings=_CONCAT_FRAME_BINDINGS,
    capture=_capture_concat_frame,
    handler=_concat_frame_recipe,
    validate_params=_validate_concat_frame_recipe,
)
def concat_frame(
    self,
    other: "ChannelFrame",
    label_prefix: str | None = None,
    align: str = "strict",
    suffix_on_dup: str | None = None,
) -> "ChannelFrame":
    """Concatenate all channels from another frame along the channel axis.

    The result preserves ``other`` channel metadata, calibration, and
    source-time offsets. Neither input frame is changed.

    Args:
        other: ChannelFrame whose channels are appended in their current order.
        label_prefix: Optional prefix for appended labels, producing
            ``"{label_prefix}_{original_label}"``.
        align: ``"strict"`` rejects sample-length differences; ``"pad"`` and
            ``"truncate"`` align the appended data to this frame's length.
        suffix_on_dup: Suffix for duplicate labels. If None, duplicates raise.

    Returns:
        A new lazy ChannelFrame containing both channel collections.

    Raises:
        TypeError: If other is not a ChannelFrame.
        ValueError: If sampling rates, lengths, or labels are incompatible.
    """
    _normalize_channel_operation_params(
        {
            "label_prefix": label_prefix,
            "align": align,
            "suffix_on_dup": suffix_on_dup,
        },
        label_name="label_prefix",
        allow_source_time_offset=False,
    )
    if not isinstance(other, ChannelFrame):
        raise TypeError("concat_frame() other must be a ChannelFrame")
    if self.sampling_rate != other.sampling_rate:
        raise ValueError("sampling_rate mismatch")
    arr = _align_to_length(other._data, self.n_samples, align, other.n_samples)
    labels = self.labels
    new_labels: list[str] = []
    new_metadata_list = other._borrowed_channel_metadata_descriptors()
    for descriptor, chmeta in zip(new_metadata_list, other.channels, strict=True):
        new_label = f"{label_prefix}_{chmeta.label}" if label_prefix is not None else chmeta.label
        if new_label in labels or new_label in new_labels:
            if suffix_on_dup:
                new_label += suffix_on_dup
            else:
                raise ValueError(
                    f"Duplicate channel label\n"
                    f"  Label: '{new_label}'\n"
                    f"  Existing labels: {labels + new_labels}\n"
                    f"Use suffix_on_dup parameter to automatically "
                    f"rename duplicates."
                )
        new_labels.append(new_label)
        descriptor["label"] = new_label
    new_data = concatenate([self._data, arr], axis=0)
    new_chmeta = self._borrowed_channel_metadata_descriptors() + new_metadata_list
    new_ids = self._channel_ids.copy()
    for _ in new_metadata_list:
        new_ids.append(self._next_channel_id(new_ids))
    new_offsets = np.concatenate([self.source_time_offset, other.source_time_offset])
    return self._finalize_channel_update(
        new_data,
        new_chmeta,
        new_ids,
        new_offsets,
        lineage=self._required_semantic_lineage(),
    )

remove_channel(key)

Return a new frame without one channel.

Parameters:

Name Type Description Default
key int | str

Zero-based channel index or exact channel label to remove.

required

Returns:

Type Description
ChannelFrame

A lazy ChannelFrame preserving the remaining channels' metadata, stable channel identifiers, source-time offsets, and semantic lineage.

Raises:

Type Description
IndexError

If an integer index is outside the channel range.

KeyError

If a string label does not exist.

Source code in wandas/frames/channel.py
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
@recipe_operation("wandas.channel.remove_channel")
def remove_channel(self, key: int | str) -> "ChannelFrame":
    """Return a new frame without one channel.

    Args:
        key: Zero-based channel index or exact channel label to remove.

    Returns:
        A lazy ChannelFrame preserving the remaining channels' metadata, stable
            channel identifiers, source-time offsets, and semantic lineage.

    Raises:
        IndexError: If an integer index is outside the channel range.
        KeyError: If a string label does not exist.
    """
    if isinstance(key, int):
        if not (0 <= key < self.n_channels):
            raise IndexError(f"index {key} out of range")
        idx = key
    else:
        labels = self.labels
        if key not in labels:
            raise KeyError(f"label {key} not found")
        idx = labels.index(key)
    keep_indices = [i for i in range(self.n_channels) if i != idx]
    new_data = self._data[keep_indices, :]
    new_chmeta = self._borrowed_channel_metadata_descriptors(keep_indices)
    new_ids = [self._channel_ids[i] for i in keep_indices]
    return self._finalize_channel_update(
        new_data,
        new_chmeta,
        new_ids,
        self.source_time_offset[keep_indices],
        lineage=self._required_semantic_lineage(),
    )

wandas.frames.spectral.SpectralFrame

Bases: SpectralPropertiesMixin, BaseFrame[NDArrayComplex]

Class for handling frequency-domain signal data.

This class represents spectral data, providing methods for spectral analysis, manipulation, and visualization. It handles complex-valued frequency domain data obtained through operations like FFT.

Parameters:

Name Type Description Default
data Array

DaArray. The spectral data. Must be a dask array with shape: - (channels, frequency_bins) for multi-channel data - (frequency_bins,) for single-channel data, which will be reshaped to (1, frequency_bins)

required
sampling_rate float

float. The sampling rate of the original time-domain signal in Hz.

required
n_fft int

int. Required. The FFT size used to generate this spectral data. Must be a positive integer and must be the same FFT size that was used to create the complete one-sided spectrum (for example, 512 or 1024). The data must contain exactly n_fft // 2 + 1 frequency bins.

required
window str

str, default="hann". The window function used in the FFT.

'hann'
label str | None

str, optional. A label for the frame.

None
metadata dict[str, Any] | None

dict, optional. Additional metadata for the frame.

None
lineage Any | None

LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is created. operation_history is its public derived projection.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

list[ChannelMetadata], optional. Metadata for each channel in the frame.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None

Attributes:

Name Type Description
magnitude NDArrayReal

NDArrayReal. Absolute value of the stored spectral quantity. FFT and Welch results are amplitudes in the input channel unit.

phase NDArrayReal

NDArrayReal. The phase spectrum in radians.

unwrapped_phase NDArrayReal

NDArrayReal. The unwrapped phase spectrum in radians.

power NDArrayReal

NDArrayReal. Squared magnitude. This compatibility property is not necessarily physical power or power spectral density.

dB NDArrayReal

NDArrayReal. Magnitude level, 20 * log10(magnitude / channel_ref). For FFT and Welch results this is an amplitude level.

dBA NDArrayReal

NDArrayReal. A-weighted magnitude level. For FFT and Welch results this is an A-weighted amplitude level.

freqs NDArrayReal

NDArrayReal. The frequency axis values in Hz.

Examples:

Create a SpectralFrame from FFT:

>>> signal = ChannelFrame.from_numpy(data, sampling_rate=44100)
>>> spectrum = signal.fft(n_fft=2048)

Plot the amplitude level spectrum:

>>> spectrum.plot()

Perform binary operations:

>>> scaled = spectrum * 2.0
>>> summed = spectrum1 + spectrum2  # Must have matching sampling rates

Convert back to time domain:

>>> time_signal = spectrum.ifft()
Notes
  • All operations are performed lazily using dask arrays for efficient memory usage.
  • Binary operations (+, -, *, /) can be performed between SpectralFrames or with

scalar values. - The class maintains runtime lineage and metadata through all operations.

Source code in wandas/frames/spectral.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
class SpectralFrame(SpectralPropertiesMixin, BaseFrame[NDArrayComplex]):
    """
    Class for handling frequency-domain signal data.

    This class represents spectral data, providing methods for spectral analysis,
    manipulation, and visualization. It handles complex-valued frequency domain data
    obtained through operations like FFT.

    Args:
        data: DaArray. The spectral data. Must be a dask array with shape:
            - (channels, frequency_bins) for multi-channel data
            - (frequency_bins,) for single-channel data, which will be
            reshaped to (1, frequency_bins)
        sampling_rate: float. The sampling rate of the original time-domain signal in Hz.
        n_fft: int. Required. The FFT size used to generate this spectral data. Must be a
            positive integer and must be the same FFT size that was used to create
            the complete one-sided spectrum (for example, 512 or 1024). The data must
            contain exactly ``n_fft // 2 + 1`` frequency bins.
        window: str, default="hann". The window function used in the FFT.
        label: str, optional. A label for the frame.
        metadata: dict, optional. Additional metadata for the frame.
        lineage: LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is
            created. ``operation_history`` is its public derived projection.
        channel_metadata: list[ChannelMetadata], optional. Metadata for each channel in the frame.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.

    Attributes:
        magnitude: NDArrayReal. Absolute value of the stored spectral quantity. FFT and Welch results
            are amplitudes in the input channel unit.
        phase: NDArrayReal. The phase spectrum in radians.
        unwrapped_phase: NDArrayReal. The unwrapped phase spectrum in radians.
        power: NDArrayReal. Squared magnitude. This compatibility property is not necessarily
            physical power or power spectral density.
        dB: NDArrayReal. Magnitude level, ``20 * log10(magnitude / channel_ref)``. For FFT and
            Welch results this is an amplitude level.
        dBA: NDArrayReal. A-weighted magnitude level. For FFT and Welch results this is an
            A-weighted amplitude level.
        freqs: NDArrayReal. The frequency axis values in Hz.

    Examples:
        Create a SpectralFrame from FFT:
        >>> signal = ChannelFrame.from_numpy(data, sampling_rate=44100)
        >>> spectrum = signal.fft(n_fft=2048)

        Plot the amplitude level spectrum:
        >>> spectrum.plot()

        Perform binary operations:
        >>> scaled = spectrum * 2.0
        >>> summed = spectrum1 + spectrum2  # Must have matching sampling rates

        Convert back to time domain:
        >>> time_signal = spectrum.ifft()

    Notes:
        - All operations are performed lazily using dask arrays for efficient memory usage.
        - Binary operations (+, -, *, /) can be performed between SpectralFrames or with
      scalar values.
        - The class maintains runtime lineage and metadata through all operations.
    """

    _xarray_dim_suffix = ("channel", "frequency")

    n_fft: int
    window: str

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        """Initialize a complete canonical one-sided spectrum.

        See the class docstring for parameter descriptions. The frequency axis is
        derived from ``sampling_rate`` and ``n_fft`` rather than stored as mutable
        coordinate state.
        """
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim > 2:
            raise ValueError(f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}")
        if n_fft <= 0:
            raise ValueError(
                "Invalid n_fft for SpectralFrame\n"
                f"  Got: {n_fft}\n"
                "  Expected: a positive integer\n"
                "Pass the FFT size used to produce this spectrum."
            )
        expected_bins = n_fft // 2 + 1
        if int(data.shape[-1]) != expected_bins:
            raise ValueError(
                "Invalid frequency bin count for SpectralFrame\n"
                f"  Got: {data.shape[-1]} bins\n"
                f"  Expected: {expected_bins} bins for n_fft={n_fft}\n"
                "Use the complete canonical one-sided spectrum."
            )
        self.n_fft = n_fft
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
            previous=previous,
        )

    @property
    def unwrapped_phase(self) -> NDArrayReal:
        """
        Get the unwrapped phase spectrum.

        The unwrapped phase removes discontinuities of 2π radians, providing
        continuous phase values across frequency bins.

        Returns:
            NDArrayReal: The unwrapped phase angles of the complex spectrum in radians.
        """
        return np.unwrap(np.angle(self.data))

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the frequency axis values in Hz.

        Values are derived on access from ``sampling_rate`` and ``n_fft`` using the
        canonical one-sided real-FFT grid. They are not duplicated in Frame state.

        Returns:
            NDArrayReal: Array of frequency values corresponding to each frequency bin.

        """
        return np.fft.rfftfreq(self.n_fft, 1.0 / self.sampling_rate)

    def plot(
        self,
        plot_type: str = "frequency",
        ax: Axes | None = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """
        Plot the spectral data using various visualization strategies.

        Args:
            plot_type: str, default="frequency". Type of plot to create. Options include:
                - "frequency": Standard frequency plot
                - "matrix": Matrix plot for comparing channels
                - Other types as defined by available plot strategies
            ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
            title: str, optional. Title for the plot. If None, uses the frame label.
            overlay: bool, default=False. Whether to overlay all channels on a single plot (True)
                or create separate subplots for each channel (False).
            xlabel: str, optional. Label for the x-axis. If None, uses default "Frequency [Hz]".
            ylabel: str, optional. Label for the y-axis. If None, uses default based on data type.
            alpha: float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).
            xlim: tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.
            ylim: tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.
            Aw: bool, default=False. Whether to apply A-weighting to the data.
            **kwargs: dict. Additional matplotlib Line2D parameters
                (e.g., color, linewidth, linestyle).

        Returns:
            Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
                for multi-plot outputs.

        Examples:
            >>> spectrum = cf.fft()
            >>> # Basic frequency plot
            >>> spectrum.plot()
            >>> # Overlay with A-weighting
            >>> spectrum.plot(overlay=True, Aw=True)
            >>> # Custom styling
            >>> spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            "Aw": Aw,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    @recipe_operation("wandas.spectral.ifft", version=2)
    def ifft(self) -> ChannelFrame:
        """
        Invert Wandas FFT normalization to a windowed time-domain signal.

        For a spectrum returned by ``ChannelFrame.fft()`` with matching stored
        ``n_fft`` and ``window``, this returns the truncated-or-zero-padded
        analysis input multiplied by the FFT window. With ``window="boxcar"``,
        that prepared input is reconstructed exactly. A tapered window such as
        the default Hann window is not divided out because its zero-valued
        samples cannot be recovered. Graph construction remains lazy.

        Returns:
            ChannelFrame: A new ChannelFrame containing the windowed time-domain signal in
                the original channel unit.

        """
        from ..processing import create_operation

        params = {"n_fft": self.n_fft, "window": self.window}
        operation_name = "ifft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("IFFT", operation)
        return self._ifft_with_operation(operation)

    @recipe_operation("wandas.spectral.ifft", version=1)
    def _ifft_recipe_v1(self) -> ChannelFrame:
        """Replay the released Recipe v1 IFFT amplitude-scaling contract."""
        from ..processing.spectral import _RecipeIFFTV1

        operation = _RecipeIFFTV1(
            self.sampling_rate,
            n_fft=self.n_fft,
            window=self.window,
        )
        return self._ifft_with_operation(operation)

    def _ifft_with_operation(self, operation: IFFT) -> ChannelFrame:
        """Build an inverse transform while preserving Frame orchestration."""
        from .channel import ChannelFrame

        # Apply processing to data
        time_series = operation.process(self._data)

        logger.debug("Created new ChannelFrame with IFFT operation added to graph")

        # Create new instance
        lineage = self._required_semantic_lineage()
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"ifft({self.label})",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            source_time_offset=self.source_time_offset,
            lineage=lineage,
            previous=self,
        )

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Provide additional initialization arguments required for SpectralFrame.

        Returns:
            dict[str, Any]: Additional initialization arguments for SpectralFrame.
        """
        return {
            "n_fft": self.n_fft,
            "window": self.window,
        }

    def _get_dataframe_index(self) -> pd.Index[Any]:
        """Get frequency index for DataFrame."""
        pd = require_pandas("SpectralFrame.to_dataframe")
        return pd.Index(self.freqs, name="frequency")

    @recipe_operation(
        "wandas.spectral.noct_synthesis",
        version=2,
        validate_params=validate_noct_recipe_params,
    )
    def noct_synthesis(
        self,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> NOctFrame:
        """
        Synthesize N-octave band spectrum.

        This method combines frequency components into N-octave bands according to
        standard acoustical band definitions. This is commonly used in noise and
        vibration analysis. The authoritative FFT size is read from this
        ``SpectralFrame.n_fft``; it is not a new caller-supplied parameter.

        Args:
            fmin: float. Lower frequency bound in Hz.
            fmax: float. Upper frequency bound in Hz.
            n: int, default=3. Number of bands per octave (e.g., 3 for third-octave bands).
            G: int, default=10. Exact center-frequency ratio convention. Use 10 for base
                ``10**(3/10)`` or 2 for base 2.
            fr: int, default=1000. Reference frequency in Hz.

        Returns:
            NOctFrame: A new NOctFrame containing the N-octave band spectrum.

        Raises:
            ValueError: If the sampling rate is not 48000 Hz.
            TypeError: If ``G`` is not an integer ratio convention.
        """
        if self.sampling_rate != 48000:
            raise ValueError("noct_synthesis can only be used with a sampling rate of 48000 Hz.")
        from ..processing import NOctSynthesis

        params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
        operation_name = "noct_synthesis"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params, n_fft=self.n_fft)
        operation = cast("NOctSynthesis", operation)
        return self._noct_synthesis_with_operation(
            operation,
            fmin=fmin,
            fmax=fmax,
            n=n,
            g=G,
            fr=fr,
        )

    @recipe_operation(
        "wandas.spectral.noct_synthesis",
        version=1,
        validate_params=validate_noct_recipe_params,
    )
    def _noct_synthesis_recipe_v1(
        self,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> NOctFrame:
        """Replay the released Recipe v1 bin-inference contract."""
        if self.sampling_rate != 48000:
            raise ValueError("noct_synthesis can only be used with a sampling rate of 48000 Hz.")
        from ..processing.spectral import _RecipeNOctSynthesisV1

        operation = _RecipeNOctSynthesisV1(
            self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=G,
            fr=fr,
        )
        return self._noct_synthesis_with_operation(
            operation,
            fmin=fmin,
            fmax=fmax,
            n=n,
            g=G,
            fr=fr,
        )

    def _noct_synthesis_with_operation(
        self,
        operation: Any,
        *,
        fmin: float,
        fmax: float,
        n: int,
        g: int,
        fr: int,
    ) -> NOctFrame:
        """Build an NOctFrame from either the current or legacy operation."""
        from .noct import NOctFrame

        spectrum_data = operation.process(self._data)
        logger.debug("Created new NOctFrame with N-octave synthesis operation added to graph")
        lineage = self._required_semantic_lineage()
        return NOctFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=g,
            fr=fr,
            label=f"1/{n}Oct of {self.label}",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            source_time_offset=self.source_time_offset,
            lineage=lineage,
            previous=self,
        )

    def plot_matrix(
        self,
        plot_type: str = "matrix",
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """
        Plot channel relationships in matrix format.

        This method creates a matrix plot showing relationships between channels,
        such as coherence, transfer functions, or cross-spectral density.

        Args:
            plot_type: str, default="matrix". Type of matrix plot to create.
            **kwargs: dict. Additional plot parameters:
                - vmin, vmax: Color scale limits
                - cmap: Colormap name
                - title: Plot title

        Returns:
            Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

        # Execute plot
        _ax = plot_strategy.plot(self, **kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def info(self) -> None:
        """Display comprehensive information about the SpectralFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - FFT size
        - Frequency range
        - Number of frequency bins
        - Frequency resolution (ΔF)
        - Channel labels

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples:
            >>> spectrum = cf.fft()
            >>> spectrum.info()
            SpectralFrame Information:
              Channels: 2
              Sampling rate: 44100 Hz
              FFT size: 2048
              Frequency range: 0.0 - 22050.0 Hz
              Frequency bins: 1025
              Frequency resolution (ΔF): 21.5 Hz
              Channel labels: ['ch0', 'ch1']
              Operations Applied: 1
        """
        # Calculate frequency resolution (ΔF)
        delta_f = self.sampling_rate / self.n_fft

        print("SpectralFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  FFT size: {self.n_fft}")
        print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
        print(f"  Frequency bins: {len(self.freqs)}")
        print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

Attributes

n_fft = n_fft instance-attribute

window = window instance-attribute

unwrapped_phase property

Get the unwrapped phase spectrum.

The unwrapped phase removes discontinuities of 2π radians, providing continuous phase values across frequency bins.

Returns:

Name Type Description
NDArrayReal NDArrayReal

The unwrapped phase angles of the complex spectrum in radians.

freqs property

Get the frequency axis values in Hz.

Values are derived on access from sampling_rate and n_fft using the canonical one-sided real-FFT grid. They are not duplicated in Frame state.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Array of frequency values corresponding to each frequency bin.

Functions

__init__(data, sampling_rate, n_fft, window='hann', label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Initialize a complete canonical one-sided spectrum.

See the class docstring for parameter descriptions. The frequency axis is derived from sampling_rate and n_fft rather than stored as mutable coordinate state.

Source code in wandas/frames/spectral.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    """Initialize a complete canonical one-sided spectrum.

    See the class docstring for parameter descriptions. The frequency axis is
    derived from ``sampling_rate`` and ``n_fft`` rather than stored as mutable
    coordinate state.
    """
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim > 2:
        raise ValueError(f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}")
    if n_fft <= 0:
        raise ValueError(
            "Invalid n_fft for SpectralFrame\n"
            f"  Got: {n_fft}\n"
            "  Expected: a positive integer\n"
            "Pass the FFT size used to produce this spectrum."
        )
    expected_bins = n_fft // 2 + 1
    if int(data.shape[-1]) != expected_bins:
        raise ValueError(
            "Invalid frequency bin count for SpectralFrame\n"
            f"  Got: {data.shape[-1]} bins\n"
            f"  Expected: {expected_bins} bins for n_fft={n_fft}\n"
            "Use the complete canonical one-sided spectrum."
        )
    self.n_fft = n_fft
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
        previous=previous,
    )

plot(plot_type='frequency', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, Aw=False, **kwargs)

Plot the spectral data using various visualization strategies.

Parameters:

Name Type Description Default
plot_type str

str, default="frequency". Type of plot to create. Options include: - "frequency": Standard frequency plot - "matrix": Matrix plot for comparing channels - Other types as defined by available plot strategies

'frequency'
ax Axes | None

matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.

None
title str | None

str, optional. Title for the plot. If None, uses the frame label.

None
overlay bool

bool, default=False. Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False).

False
xlabel str | None

str, optional. Label for the x-axis. If None, uses default "Frequency [Hz]".

None
ylabel str | None

str, optional. Label for the y-axis. If None, uses default based on data type.

None
alpha float

float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).

1.0
xlim tuple[float, float] | None

tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.

None
ylim tuple[float, float] | None

tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.

None
Aw bool

bool, default=False. Whether to apply A-weighting to the data.

False
**kwargs Any

dict. Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle).

{}

Returns:

Type Description
Axes | Iterator[Axes]

Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples:

>>> spectrum = cf.fft()
>>> # Basic frequency plot
>>> spectrum.plot()
>>> # Overlay with A-weighting
>>> spectrum.plot(overlay=True, Aw=True)
>>> # Custom styling
>>> spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)
Source code in wandas/frames/spectral.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def plot(
    self,
    plot_type: str = "frequency",
    ax: Axes | None = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """
    Plot the spectral data using various visualization strategies.

    Args:
        plot_type: str, default="frequency". Type of plot to create. Options include:
            - "frequency": Standard frequency plot
            - "matrix": Matrix plot for comparing channels
            - Other types as defined by available plot strategies
        ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
        title: str, optional. Title for the plot. If None, uses the frame label.
        overlay: bool, default=False. Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel: str, optional. Label for the x-axis. If None, uses default "Frequency [Hz]".
        ylabel: str, optional. Label for the y-axis. If None, uses default based on data type.
        alpha: float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).
        xlim: tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.
        ylim: tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.
        Aw: bool, default=False. Whether to apply A-weighting to the data.
        **kwargs: dict. Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).

    Returns:
        Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

    Examples:
        >>> spectrum = cf.fft()
        >>> # Basic frequency plot
        >>> spectrum.plot()
        >>> # Overlay with A-weighting
        >>> spectrum.plot(overlay=True, Aw=True)
        >>> # Custom styling
        >>> spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        "Aw": Aw,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax

ifft()

Invert Wandas FFT normalization to a windowed time-domain signal.

For a spectrum returned by ChannelFrame.fft() with matching stored n_fft and window, this returns the truncated-or-zero-padded analysis input multiplied by the FFT window. With window="boxcar", that prepared input is reconstructed exactly. A tapered window such as the default Hann window is not divided out because its zero-valued samples cannot be recovered. Graph construction remains lazy.

Returns:

Name Type Description
ChannelFrame ChannelFrame

A new ChannelFrame containing the windowed time-domain signal in the original channel unit.

Source code in wandas/frames/spectral.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
@recipe_operation("wandas.spectral.ifft", version=2)
def ifft(self) -> ChannelFrame:
    """
    Invert Wandas FFT normalization to a windowed time-domain signal.

    For a spectrum returned by ``ChannelFrame.fft()`` with matching stored
    ``n_fft`` and ``window``, this returns the truncated-or-zero-padded
    analysis input multiplied by the FFT window. With ``window="boxcar"``,
    that prepared input is reconstructed exactly. A tapered window such as
    the default Hann window is not divided out because its zero-valued
    samples cannot be recovered. Graph construction remains lazy.

    Returns:
        ChannelFrame: A new ChannelFrame containing the windowed time-domain signal in
            the original channel unit.

    """
    from ..processing import create_operation

    params = {"n_fft": self.n_fft, "window": self.window}
    operation_name = "ifft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("IFFT", operation)
    return self._ifft_with_operation(operation)

noct_synthesis(fmin, fmax, n=3, G=10, fr=1000)

Synthesize N-octave band spectrum.

This method combines frequency components into N-octave bands according to standard acoustical band definitions. This is commonly used in noise and vibration analysis. The authoritative FFT size is read from this SpectralFrame.n_fft; it is not a new caller-supplied parameter.

Parameters:

Name Type Description Default
fmin float

float. Lower frequency bound in Hz.

required
fmax float

float. Upper frequency bound in Hz.

required
n int

int, default=3. Number of bands per octave (e.g., 3 for third-octave bands).

3
G int

int, default=10. Exact center-frequency ratio convention. Use 10 for base 10**(3/10) or 2 for base 2.

10
fr int

int, default=1000. Reference frequency in Hz.

1000

Returns:

Name Type Description
NOctFrame NOctFrame

A new NOctFrame containing the N-octave band spectrum.

Raises:

Type Description
ValueError

If the sampling rate is not 48000 Hz.

TypeError

If G is not an integer ratio convention.

Source code in wandas/frames/spectral.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
@recipe_operation(
    "wandas.spectral.noct_synthesis",
    version=2,
    validate_params=validate_noct_recipe_params,
)
def noct_synthesis(
    self,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
) -> NOctFrame:
    """
    Synthesize N-octave band spectrum.

    This method combines frequency components into N-octave bands according to
    standard acoustical band definitions. This is commonly used in noise and
    vibration analysis. The authoritative FFT size is read from this
    ``SpectralFrame.n_fft``; it is not a new caller-supplied parameter.

    Args:
        fmin: float. Lower frequency bound in Hz.
        fmax: float. Upper frequency bound in Hz.
        n: int, default=3. Number of bands per octave (e.g., 3 for third-octave bands).
        G: int, default=10. Exact center-frequency ratio convention. Use 10 for base
            ``10**(3/10)`` or 2 for base 2.
        fr: int, default=1000. Reference frequency in Hz.

    Returns:
        NOctFrame: A new NOctFrame containing the N-octave band spectrum.

    Raises:
        ValueError: If the sampling rate is not 48000 Hz.
        TypeError: If ``G`` is not an integer ratio convention.
    """
    if self.sampling_rate != 48000:
        raise ValueError("noct_synthesis can only be used with a sampling rate of 48000 Hz.")
    from ..processing import NOctSynthesis

    params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
    operation_name = "noct_synthesis"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
    from ..processing import create_operation

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params, n_fft=self.n_fft)
    operation = cast("NOctSynthesis", operation)
    return self._noct_synthesis_with_operation(
        operation,
        fmin=fmin,
        fmax=fmax,
        n=n,
        g=G,
        fr=fr,
    )

plot_matrix(plot_type='matrix', **kwargs)

Plot channel relationships in matrix format.

This method creates a matrix plot showing relationships between channels, such as coherence, transfer functions, or cross-spectral density.

Parameters:

Name Type Description Default
plot_type str

str, default="matrix". Type of matrix plot to create.

'matrix'
**kwargs Any

dict. Additional plot parameters: - vmin, vmax: Color scale limits - cmap: Colormap name - title: Plot title

{}

Returns:

Type Description
Axes | Iterator[Axes]

Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.

Source code in wandas/frames/spectral.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
def plot_matrix(
    self,
    plot_type: str = "matrix",
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """
    Plot channel relationships in matrix format.

    This method creates a matrix plot showing relationships between channels,
    such as coherence, transfer functions, or cross-spectral density.

    Args:
        plot_type: str, default="matrix". Type of matrix plot to create.
        **kwargs: dict. Additional plot parameters:
            - vmin, vmax: Color scale limits
            - cmap: Colormap name
            - title: Plot title

    Returns:
        Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

    # Execute plot
    _ax = plot_strategy.plot(self, **kwargs)

    logger.debug("Plot rendering complete")

    return _ax

info()

Display comprehensive information about the SpectralFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - FFT size - Frequency range - Number of frequency bins - Frequency resolution (ΔF) - Channel labels

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples:

>>> spectrum = cf.fft()
>>> spectrum.info()
SpectralFrame Information:
  Channels: 2
  Sampling rate: 44100 Hz
  FFT size: 2048
  Frequency range: 0.0 - 22050.0 Hz
  Frequency bins: 1025
  Frequency resolution (ΔF): 21.5 Hz
  Channel labels: ['ch0', 'ch1']
  Operations Applied: 1
Source code in wandas/frames/spectral.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def info(self) -> None:
    """Display comprehensive information about the SpectralFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - FFT size
    - Frequency range
    - Number of frequency bins
    - Frequency resolution (ΔF)
    - Channel labels

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples:
        >>> spectrum = cf.fft()
        >>> spectrum.info()
        SpectralFrame Information:
          Channels: 2
          Sampling rate: 44100 Hz
          FFT size: 2048
          Frequency range: 0.0 - 22050.0 Hz
          Frequency bins: 1025
          Frequency resolution (ΔF): 21.5 Hz
          Channel labels: ['ch0', 'ch1']
          Operations Applied: 1
    """
    # Calculate frequency resolution (ΔF)
    delta_f = self.sampling_rate / self.n_fft

    print("SpectralFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  FFT size: {self.n_fft}")
    print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
    print(f"  Frequency bins: {len(self.freqs)}")
    print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()

wandas.frames.pairwise.CoherenceFrame

Bases: PairwiseSpectralFrame

Typed storage for dimensionless magnitude-squared coherence.

data is real numeric, rank one or two, and uses flattened (pair, frequency) storage internally. A single pair is exposed with the normal single-channel public shape. n_fft, window, and pair_state are required constructor state; frequency_indices may retain a selected, ordered subset of the n_fft // 2 + 1 frequency bins. pair_state supplies the output/input roles, source identity, dimensionless domain, and row order; labels and lineage do not define quantity meaning.

Magnitude-squared coherence produced by :meth:ChannelFrame.coherence is mathematically in [0, 1], with NaN for undefined bins. This constructor validates array structure and typed state, but does not scan, clip, or otherwise validate array values from direct construction, WDF decoding, or external Dask inputs. The constructor and all public operations remain lazy for Dask input. Pair selection, frequency slicing, metadata changes, and annotation copies preserve this concrete type and the corresponding typed rows; arithmetic, amplitude-level APIs, inverse FFT, synthesis, and A-weighting are not defined for this Frame.

Source code in wandas/frames/pairwise.py
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
class CoherenceFrame(PairwiseSpectralFrame):
    """Typed storage for dimensionless magnitude-squared coherence.

    ``data`` is real numeric, rank one or two, and uses flattened
    ``(pair, frequency)`` storage internally.  A single pair is exposed with
    the normal single-channel public shape.  ``n_fft``, ``window``, and
    ``pair_state`` are required constructor state; ``frequency_indices`` may
    retain a selected, ordered subset of the ``n_fft // 2 + 1`` frequency
    bins.  ``pair_state`` supplies the output/input roles, source identity,
    dimensionless domain, and row order; labels and lineage do not define
    quantity meaning.

    Magnitude-squared coherence produced by :meth:`ChannelFrame.coherence` is
    mathematically in ``[0, 1]``, with NaN for undefined bins.  This constructor
    validates array structure and typed state, but does not scan, clip, or
    otherwise validate array values from direct construction, WDF decoding, or
    external Dask inputs.  The constructor and all public operations remain lazy
    for Dask input.  Pair selection, frequency slicing, metadata changes, and
    annotation copies preserve this concrete type and the corresponding typed
    rows; arithmetic, amplitude-level APIs, inverse FFT, synthesis, and
    A-weighting are not defined for this Frame.
    """

    _pair_quantity = "coherence"

    def __init__(
        self,
        data: DaArray | np.ndarray[Any, Any],
        sampling_rate: float,
        n_fft: int,
        window: str,
        pair_state: Sequence[SpectralPairState],
        frequency_indices: Sequence[int] | None = None,
        source_channel_ids: Sequence[str] | None = None,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            n_fft=n_fft,
            window=window,
            pair_state=pair_state,
            frequency_indices=frequency_indices,
            source_channel_ids=source_channel_ids,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
        )

    @property
    def coherence(self) -> NDArrayReal:
        """Return raw magnitude-squared coherence values in the public shape."""
        return cast(NDArrayReal, self.data)

    def _plot_frequency_values(
        self,
        *,
        view: str | None,
        Aw: bool,  # noqa: N803
    ) -> tuple[np.ndarray[Any, Any], str]:
        return np.asarray(self.coherence), self._plot_ylabel(view=view, Aw=Aw)

    def _plot_ylabel(self, *, view: str | None, Aw: bool) -> str:  # noqa: N803
        reject_pairwise_a_weighting(Aw)
        if view not in {None, "coherence", "raw"}:
            raise ValueError("CoherenceFrame supports only the 'coherence' plot view")
        return "Coherence"

Attributes

coherence property

Return raw magnitude-squared coherence values in the public shape.

Functions

__init__(data, sampling_rate, n_fft, window, pair_state, frequency_indices=None, source_channel_ids=None, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Source code in wandas/frames/pairwise.py
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
def __init__(
    self,
    data: DaArray | np.ndarray[Any, Any],
    sampling_rate: float,
    n_fft: int,
    window: str,
    pair_state: Sequence[SpectralPairState],
    frequency_indices: Sequence[int] | None = None,
    source_channel_ids: Sequence[str] | None = None,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        n_fft=n_fft,
        window=window,
        pair_state=pair_state,
        frequency_indices=frequency_indices,
        source_channel_ids=source_channel_ids,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
    )

wandas.frames.pairwise.CrossSpectralFrame

Bases: PairwiseSpectralFrame

Complex cross-spectral values P_out_in with typed pair domains.

data is complex numeric, rank one or two, with flattened (pair, frequency) storage internally. The required constructor state is sampling_rate, n_fft, window, scaling, and immutable pair_state; frequency_indices selects an ordered subset of the n_fft // 2 + 1 bins. Each pair represents conj(X_input) * X_output and its typed domain determines the channel unit, reference, and level denominator. scaling is either "spectrum" or "density"; density domains include /Hz.

magnitude, phase, and level_db are the quantity-specific public projections, where level_db uses 10 * log10 of the magnitude/reference ratio. Dask input remains lazy. Pair selection, frequency slicing, metadata changes, and annotation copies preserve the concrete type and row-matched pair state; arithmetic and A-weighting are rejected rather than inferred from labels or history.

Source code in wandas/frames/pairwise.py
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
class CrossSpectralFrame(PairwiseSpectralFrame):
    """Complex cross-spectral values ``P_out_in`` with typed pair domains.

    ``data`` is complex numeric, rank one or two, with flattened
    ``(pair, frequency)`` storage internally.  The required constructor state
    is ``sampling_rate``, ``n_fft``, ``window``, ``scaling``, and immutable
    ``pair_state``; ``frequency_indices`` selects an ordered subset of the
    ``n_fft // 2 + 1`` bins.  Each pair represents
    ``conj(X_input) * X_output`` and its typed domain determines the channel
    unit, reference, and level denominator.  ``scaling`` is either
    ``"spectrum"`` or ``"density"``; density domains include ``/Hz``.

    ``magnitude``, ``phase``, and ``level_db`` are the quantity-specific
    public projections, where ``level_db`` uses ``10 * log10`` of the
    magnitude/reference ratio.  Dask input remains lazy.  Pair selection,
    frequency slicing, metadata changes, and annotation copies preserve the
    concrete type and row-matched pair state; arithmetic and A-weighting are
    rejected rather than inferred from labels or history.
    """

    _pair_quantity = "csd"

    def __init__(
        self,
        data: DaArray | np.ndarray[Any, Any],
        sampling_rate: float,
        n_fft: int,
        window: str,
        pair_state: Sequence[SpectralPairState],
        *,
        scaling: SpectralScaling,
        frequency_indices: Sequence[int] | None = None,
        source_channel_ids: Sequence[str] | None = None,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        if scaling not in {"spectrum", "density"}:
            raise ValueError("CrossSpectralFrame scaling must be 'spectrum' or 'density'")
        self._scaling = scaling
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            n_fft=n_fft,
            window=window,
            pair_state=pair_state,
            frequency_indices=frequency_indices,
            source_channel_ids=source_channel_ids,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
        )

    @property
    def scaling(self) -> SpectralScaling:
        """Return the immutable CSD scaling contract."""
        return self._scaling

    @property
    def _data_domain(self) -> Literal["real", "complex"]:
        return "complex"

    @property
    def magnitude(self) -> NDArrayReal:
        """Return ``abs(P_out_in)`` in the public shape."""
        return cast(NDArrayReal, np.abs(self.data))

    @property
    def phase(self) -> NDArrayReal:
        """Return ``angle(P_out_in)`` in radians in the public shape."""
        return cast(NDArrayReal, np.angle(self.data))

    @property
    def level_db(self) -> NDArrayReal:
        """Return CSD level ``10 * log10(abs(P_out_in) / pair_reference)``."""
        magnitude = self._row_values(np.asarray(self.magnitude))
        levels = np.stack(
            [
                csd_level(row.astype(complex), record.domain.reference)
                for row, record in zip(magnitude, self._pair_state)
            ]
        )
        return cast(NDArrayReal, self._public_pair_values(levels))

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        result = super()._get_additional_init_kwargs()
        result["scaling"] = self.scaling
        return result

    def _plot_frequency_values(
        self,
        *,
        view: str | None,
        Aw: bool,  # noqa: N803
    ) -> tuple[np.ndarray[Any, Any], str]:
        ylabel = self._plot_ylabel(view=view, Aw=Aw)
        selected = "magnitude" if view is None else view
        if selected == "magnitude":
            return np.asarray(self.magnitude), ylabel
        if selected == "phase":
            return np.asarray(self.phase), ylabel
        if selected == "level":
            return np.asarray(self.level_db), ylabel
        raise AssertionError("CrossSpectralFrame._plot_ylabel must validate the view")  # pragma: no cover

    def _plot_ylabel(self, *, view: str | None, Aw: bool) -> str:  # noqa: N803
        reject_pairwise_a_weighting(Aw)
        selected = "magnitude" if view is None else view
        if selected == "magnitude":
            return self._unit_summary("CSD magnitude")
        if selected == "phase":
            return "CSD phase [rad]"
        if selected == "level":
            return "CSD level [dB]"
        raise ValueError("CrossSpectralFrame view must be 'magnitude', 'phase', or 'level'")

Attributes

scaling property

Return the immutable CSD scaling contract.

magnitude property

Return abs(P_out_in) in the public shape.

phase property

Return angle(P_out_in) in radians in the public shape.

level_db property

Return CSD level 10 * log10(abs(P_out_in) / pair_reference).

Functions

__init__(data, sampling_rate, n_fft, window, pair_state, *, scaling, frequency_indices=None, source_channel_ids=None, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Source code in wandas/frames/pairwise.py
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
def __init__(
    self,
    data: DaArray | np.ndarray[Any, Any],
    sampling_rate: float,
    n_fft: int,
    window: str,
    pair_state: Sequence[SpectralPairState],
    *,
    scaling: SpectralScaling,
    frequency_indices: Sequence[int] | None = None,
    source_channel_ids: Sequence[str] | None = None,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    if scaling not in {"spectrum", "density"}:
        raise ValueError("CrossSpectralFrame scaling must be 'spectrum' or 'density'")
    self._scaling = scaling
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        n_fft=n_fft,
        window=window,
        pair_state=pair_state,
        frequency_indices=frequency_indices,
        source_channel_ids=source_channel_ids,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
    )

wandas.frames.pairwise.TransferFunctionFrame

Bases: PairwiseSpectralFrame

Complex transfer values with truthful denominator and reference state.

data is complex numeric, rank one or two, with flattened (pair, frequency) storage internally. The required constructor state is sampling_rate, n_fft, window, scaling, denominator_role, and immutable pair_state; frequency_indices selects an ordered subset of the n_fft // 2 + 1 bins. The canonical denominator_role="input" stores H_out_in = P_out_in / P_in_in. The truthful legacy v1 value uses denominator_role="output" and is never relabeled as canonical v2.

gain, phase, gain_db, and transfer_level_db are the quantity-specific public projections. gain_db is available only when every selected pair is dimensionless; transfer_level_db uses each typed output/input reference ratio. Zero-denominator non-finite values remain observable. Dask input remains lazy, and selection, slicing, annotation, and metadata operations preserve the concrete type and typed rows. Arithmetic and A-weighting are rejected rather than inferred from labels or operation history.

Source code in wandas/frames/pairwise.py
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
class TransferFunctionFrame(PairwiseSpectralFrame):
    """Complex transfer values with truthful denominator and reference state.

    ``data`` is complex numeric, rank one or two, with flattened
    ``(pair, frequency)`` storage internally.  The required constructor state
    is ``sampling_rate``, ``n_fft``, ``window``, ``scaling``,
    ``denominator_role``, and immutable ``pair_state``; ``frequency_indices``
    selects an ordered subset of the ``n_fft // 2 + 1`` bins.  The canonical
    ``denominator_role="input"`` stores
    ``H_out_in = P_out_in / P_in_in``.  The truthful legacy v1 value uses
    ``denominator_role="output"`` and is never relabeled as canonical v2.

    ``gain``, ``phase``, ``gain_db``, and ``transfer_level_db`` are the
    quantity-specific public projections.  ``gain_db`` is available only when
    every selected pair is dimensionless; ``transfer_level_db`` uses each
    typed output/input reference ratio.  Zero-denominator non-finite values
    remain observable.  Dask input remains lazy, and selection, slicing,
    annotation, and metadata operations preserve the concrete type and typed
    rows.  Arithmetic and A-weighting are rejected rather than inferred from
    labels or operation history.
    """

    _pair_quantity = "transfer"

    def __init__(
        self,
        data: DaArray | np.ndarray[Any, Any],
        sampling_rate: float,
        n_fft: int,
        window: str,
        pair_state: Sequence[SpectralPairState],
        *,
        scaling: SpectralScaling,
        denominator_role: TransferDenominator = "input",
        frequency_indices: Sequence[int] | None = None,
        source_channel_ids: Sequence[str] | None = None,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        if scaling not in {"spectrum", "density"}:
            raise ValueError("TransferFunctionFrame scaling must be 'spectrum' or 'density'")
        if denominator_role not in {"input", "output"}:
            raise ValueError("TransferFunctionFrame denominator_role must be 'input' or 'output'")
        self._scaling = scaling
        self._denominator_role = denominator_role
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            n_fft=n_fft,
            window=window,
            pair_state=pair_state,
            frequency_indices=frequency_indices,
            source_channel_ids=source_channel_ids,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
        )

    @property
    def scaling(self) -> SpectralScaling:
        """Return the immutable transfer scaling contract."""
        return self._scaling

    @property
    def denominator_role(self) -> TransferDenominator:
        """Return the immutable transfer denominator definition."""
        return self._denominator_role

    @property
    def _data_domain(self) -> Literal["real", "complex"]:
        return "complex"

    @property
    def definition(self) -> str:
        """Return the persisted numerical-definition identifier."""
        return "canonical_input_denominator" if self.denominator_role == "input" else "legacy_output_denominator"

    @property
    def gain(self) -> NDArrayReal:
        """Return linear transfer gain ``abs(H_out_in)``."""
        return cast(NDArrayReal, np.abs(self.data))

    @property
    def phase(self) -> NDArrayReal:
        """Return transfer phase ``angle(H_out_in)`` in radians."""
        return cast(NDArrayReal, np.angle(self.data))

    @property
    def gain_db(self) -> NDArrayReal:
        """Return ``20 * log10(abs(H))`` for dimensionless selected pairs only."""
        if any(record.domain.unit != "1" for record in self._pair_state):
            raise ValueError(
                "gain_db is defined only for dimensionless transfer pairs. "
                "Select a same-unit pair first or use transfer_level_db for explicit unit references."
            )
        with np.errstate(divide="ignore", invalid="ignore"):
            result = 20.0 * np.log10(self._row_values(np.asarray(self.gain)))
        return cast(NDArrayReal, self._public_pair_values(result))

    @property
    def transfer_level_db(self) -> NDArrayReal:
        """Return transfer level relative to each typed output/input reference ratio."""
        gains = self._row_values(np.asarray(self.gain))
        levels = np.stack(
            [
                transfer_level(row.astype(complex), record.domain.reference)
                for row, record in zip(gains, self._pair_state)
            ]
        )
        return cast(NDArrayReal, self._public_pair_values(levels))

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        result = super()._get_additional_init_kwargs()
        result.update({"scaling": self.scaling, "denominator_role": self.denominator_role})
        return result

    def _plot_frequency_values(
        self,
        *,
        view: str | None,
        Aw: bool,  # noqa: N803
    ) -> tuple[np.ndarray[Any, Any], str]:
        ylabel = self._plot_ylabel(view=view, Aw=Aw)
        selected = "gain" if view is None else view
        if selected == "gain":
            return np.asarray(self.gain), ylabel
        if selected == "phase":
            return np.asarray(self.phase), ylabel
        if selected == "gain_db":
            return np.asarray(self.gain_db), ylabel
        if selected == "transfer_level_db":
            return np.asarray(self.transfer_level_db), ylabel
        raise AssertionError("TransferFunctionFrame._plot_ylabel must validate the view")  # pragma: no cover

    def _plot_ylabel(self, *, view: str | None, Aw: bool) -> str:  # noqa: N803
        reject_pairwise_a_weighting(Aw)
        selected = "gain" if view is None else view
        if selected == "gain":
            return self._unit_summary("Transfer gain")
        if selected == "phase":
            return "Transfer phase [rad]"
        if selected == "gain_db":
            return "Transfer gain [dB]"
        if selected == "transfer_level_db":
            return "Transfer level [dB]"
        raise ValueError("TransferFunctionFrame view must be 'gain', 'phase', 'gain_db', or 'transfer_level_db'")

Attributes

scaling property

Return the immutable transfer scaling contract.

denominator_role property

Return the immutable transfer denominator definition.

definition property

Return the persisted numerical-definition identifier.

gain property

Return linear transfer gain abs(H_out_in).

phase property

Return transfer phase angle(H_out_in) in radians.

gain_db property

Return 20 * log10(abs(H)) for dimensionless selected pairs only.

transfer_level_db property

Return transfer level relative to each typed output/input reference ratio.

Functions

__init__(data, sampling_rate, n_fft, window, pair_state, *, scaling, denominator_role='input', frequency_indices=None, source_channel_ids=None, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Source code in wandas/frames/pairwise.py
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
def __init__(
    self,
    data: DaArray | np.ndarray[Any, Any],
    sampling_rate: float,
    n_fft: int,
    window: str,
    pair_state: Sequence[SpectralPairState],
    *,
    scaling: SpectralScaling,
    denominator_role: TransferDenominator = "input",
    frequency_indices: Sequence[int] | None = None,
    source_channel_ids: Sequence[str] | None = None,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    if scaling not in {"spectrum", "density"}:
        raise ValueError("TransferFunctionFrame scaling must be 'spectrum' or 'density'")
    if denominator_role not in {"input", "output"}:
        raise ValueError("TransferFunctionFrame denominator_role must be 'input' or 'output'")
    self._scaling = scaling
    self._denominator_role = denominator_role
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        n_fft=n_fft,
        window=window,
        pair_state=pair_state,
        frequency_indices=frequency_indices,
        source_channel_ids=source_channel_ids,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
    )

wandas.frames.spectrogram.SpectrogramFrame

Bases: SpectralPropertiesMixin, BaseFrame[NDArrayComplex]

Class for handling time-frequency domain data (spectrograms).

This class represents spectrogram data obtained through Short-Time Fourier Transform (STFT) or similar time-frequency analysis methods. It provides methods for visualization, manipulation, and conversion back to time domain.

Parameters:

Name Type Description Default
data Array

DaArray. The spectrogram data. Must be a dask array with shape: - (channels, frequency_bins, time_frames) for multi-channel data - (frequency_bins, time_frames) for single-channel data, which will be reshaped to (1, frequency_bins, time_frames)

required
sampling_rate float

float. The sampling rate of the original time-domain signal in Hz.

required
n_fft int

int. The FFT size used to generate this spectrogram. The frequency dimension must contain exactly n_fft // 2 + 1 bins.

required
hop_length int

int. Number of samples between successive frames.

required
win_length int | None

int, optional. The window length in samples. If None, defaults to n_fft.

None
window str

str, default="hann". The window function to use (e.g., "hann", "hamming", "blackman").

'hann'
label str | None

str, optional. A label for the frame.

None
metadata dict[str, Any] | None

dict, optional. Additional metadata for the frame.

None
lineage Any | None

LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is created. operation_history is its public derived projection.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

list[ChannelMetadata], optional. Metadata for each channel in the frame.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None

Attributes:

Name Type Description
magnitude NDArrayReal

NDArrayReal. The magnitude spectrogram.

phase NDArrayReal

NDArrayReal. The phase spectrogram in radians.

power NDArrayReal

NDArrayReal. The power spectrogram.

dB NDArrayReal

NDArrayReal. The spectrogram in decibels relative to channel reference values.

dBA NDArrayReal

NDArrayReal. The A-weighted spectrogram in decibels.

n_frames int

int. Number of time frames.

n_freq_bins int

int. Number of frequency bins.

freqs NDArrayReal

NDArrayReal. The frequency axis values in Hz.

times NDArrayReal

NDArrayReal. The time axis values in seconds.

Examples:

Create a spectrogram from a time-domain signal:

>>> signal = ChannelFrame.from_wav("audio.wav")
>>> spectrogram = signal.stft(n_fft=2048, hop_length=512)

Extract a specific time frame:

>>> frame_at_1s = spectrogram.get_frame_at(int(1.0 * sampling_rate / hop_length))

Convert back to time domain:

>>> reconstructed = spectrogram.to_channel_frame()

Plot the spectrogram:

>>> spectrogram.plot()
Source code in wandas/frames/spectrogram.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
class SpectrogramFrame(SpectralPropertiesMixin, BaseFrame[NDArrayComplex]):
    """
    Class for handling time-frequency domain data (spectrograms).

    This class represents spectrogram data obtained through
    Short-Time Fourier Transform (STFT)
    or similar time-frequency analysis methods. It provides methods for visualization,
    manipulation, and conversion back to time domain.

    Args:
        data: DaArray. The spectrogram data. Must be a dask array with shape:
            - (channels, frequency_bins, time_frames) for multi-channel data
            - (frequency_bins, time_frames) for single-channel data, which will be
            reshaped to (1, frequency_bins, time_frames)
        sampling_rate: float. The sampling rate of the original time-domain signal in Hz.
        n_fft: int. The FFT size used to generate this spectrogram. The frequency dimension must
            contain exactly ``n_fft // 2 + 1`` bins.
        hop_length: int. Number of samples between successive frames.
        win_length: int, optional. The window length in samples. If None, defaults to n_fft.
        window: str, default="hann". The window function to use (e.g., "hann", "hamming", "blackman").
        label: str, optional. A label for the frame.
        metadata: dict, optional. Additional metadata for the frame.
        lineage: LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is
            created. ``operation_history`` is its public derived projection.
        channel_metadata: list[ChannelMetadata], optional. Metadata for each channel in the frame.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.

    Attributes:
        magnitude: NDArrayReal. The magnitude spectrogram.
        phase: NDArrayReal. The phase spectrogram in radians.
        power: NDArrayReal. The power spectrogram.
        dB: NDArrayReal. The spectrogram in decibels relative to channel reference values.
        dBA: NDArrayReal. The A-weighted spectrogram in decibels.
        n_frames: int. Number of time frames.
        n_freq_bins: int. Number of frequency bins.
        freqs: NDArrayReal. The frequency axis values in Hz.
        times: NDArrayReal. The time axis values in seconds.

    Examples:
        Create a spectrogram from a time-domain signal:
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)

        Extract a specific time frame:
        >>> frame_at_1s = spectrogram.get_frame_at(int(1.0 * sampling_rate / hop_length))

        Convert back to time domain:
        >>> reconstructed = spectrogram.to_channel_frame()

        Plot the spectrogram:
        >>> spectrogram.plot()

    """

    _xarray_dim_suffix = ("channel", "frequency", "time")

    n_fft: int
    hop_length: int
    win_length: int
    window: str

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        hop_length: int,
        win_length: int | None = None,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        lineage: Any | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: "BaseFrame[Any] | None" = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        """Initialize a complete canonical one-sided spectrogram.

        See the class docstring for parameter descriptions. Frequency and local time
        axes are derived from the analysis parameters rather than stored as mutable
        coordinate state.
        """
        if data.ndim == 2:
            data = da.expand_dims(data, axis=0)
        elif data.ndim != 3:
            raise ValueError(
                f"Invalid data dimensions\n"
                f"  Got: {data.ndim}D array with shape {data.shape}\n"
                f"  Expected: 2D or 3D array\n"
                f"Spectrograms require 2D (freq x time) or "
                f"3D (channel x freq x time) data."
            )
        if n_fft <= 0:
            raise ValueError(f"n_fft must be positive, got {n_fft}")
        if hop_length <= 0:
            raise ValueError(f"hop_length must be positive, got {hop_length}")
        resolved_win_length = n_fft if win_length is None else win_length
        if resolved_win_length <= 0:
            raise ValueError(f"win_length must be positive, got {resolved_win_length}")
        if resolved_win_length > n_fft:
            raise ValueError(
                "Invalid win_length for SpectrogramFrame\n"
                f"  Got: {resolved_win_length} for n_fft={n_fft}\n"
                "  Expected: win_length <= n_fft\n"
                "Use the analysis state of the source signal."
            )
        if hop_length > resolved_win_length:
            raise ValueError(
                "Invalid hop_length for SpectrogramFrame\n"
                f"  Got: {hop_length} for win_length={resolved_win_length}\n"
                "  Expected: hop_length <= win_length\n"
                "Use the analysis state of the source signal."
            )
        expected_bins = n_fft // 2 + 1
        if int(data.shape[-2]) != expected_bins:
            raise ValueError(
                f"Invalid frequency bin count\n"
                f"  Got: {data.shape[-2]} bins\n"
                f"  Expected: {expected_bins} bins (n_fft={n_fft})\n"
                "Use the complete canonical one-sided spectrogram."
            )

        self.n_fft = n_fft
        self.hop_length = hop_length
        self.win_length = resolved_win_length
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
            previous=previous,
        )

    @property
    def n_frames(self) -> int:
        """
        Get the number of time frames.

        Returns:
            int: The number of time frames in the spectrogram.
        """
        return self.shape[-1]

    @property
    def n_freq_bins(self) -> int:
        """
        Get the number of frequency bins.

        Returns:
            int: The number of frequency bins (n_fft // 2 + 1).
        """
        return self.shape[-2]

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the frequency axis values in Hz.

        Values are derived on access from ``sampling_rate`` and ``n_fft`` using the
        canonical one-sided real-FFT grid.

        Returns:
            NDArrayReal: Array of frequency values corresponding to each frequency bin.
        """
        return np.fft.rfftfreq(self.n_fft, 1.0 / self.sampling_rate)

    @property
    def times(self) -> NDArrayReal:
        """
        Get the time axis values in seconds.

        This is a zero-based local axis derived from ``hop_length`` and
        ``sampling_rate``. Absolute placement belongs to ``source_time_offset``.

        Returns:
            NDArrayReal: Array of time values corresponding to each time frame.
        """
        return np.arange(self.n_frames) * self.hop_length / self.sampling_rate

    @property
    def source_times(self) -> NDArrayReal:
        """Get frame times relative to the original source timeline."""
        return self.source_time_offset[:, None] + self.times[None, :]

    def plot(
        self,
        plot_type: str = "spectrogram",
        ax: "Axes | None" = None,
        title: str | None = None,
        cmap: str = "jet",
        vmin: float | None = None,
        vmax: float | None = None,
        fmin: float = 0,
        fmax: float | None = None,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        overlay: bool = False,
        **kwargs: Any,
    ) -> "Axes | Iterator[Axes]":
        """
        Plot the spectrogram using various visualization strategies.

        Args:
            plot_type: str, default="spectrogram". Type of plot to create.
            ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
            title: str, optional. Title for the plot. If None, uses the frame label.
            cmap: str, default="jet". Colormap name for the spectrogram visualization.
            vmin: float, optional. Minimum value for colormap scaling (dB). Auto-calculated if None.
            vmax: float, optional. Maximum value for colormap scaling (dB). Auto-calculated if None.
            fmin: float, default=0. Minimum frequency to display (Hz).
            fmax: float, optional. Maximum frequency to display (Hz). If None, uses Nyquist frequency.
            xlim: tuple[float, float], optional. Time axis limits as (start_time, end_time) in seconds.
            ylim: tuple[float, float], optional. Frequency axis limits as (min_freq, max_freq) in Hz.
            Aw: bool, default=False. Whether to apply A-weighting to the spectrogram.
            overlay: bool, default=False. Whether to overlay channels on a single axes.
            **kwargs: dict. Additional keyword arguments passed to Matplotlib plotting methods.

        Returns:
            Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
                for multi-plot outputs.

        Examples:
            >>> stft = cf.stft()
            >>> # Basic spectrogram
            >>> stft.plot()
            >>> # Custom color scale and frequency range
            >>> stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)
            >>> # A-weighted spectrogram
            >>> stft.plot(Aw=True, cmap="viridis")
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[SpectrogramFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "cmap": cmap,
            "vmin": vmin,
            "vmax": vmax,
            "fmin": fmin,
            "fmax": fmax,
            "Aw": Aw,
            **kwargs,
        }
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, overlay=overlay, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def plot_Aw(  # noqa: N802
        self,
        plot_type: str = "spectrogram",
        ax: "Axes | None" = None,
        **kwargs: Any,
    ) -> "Axes | Iterator[Axes]":
        """
        Plot the A-weighted spectrogram.

        A convenience method that calls plot() with Aw=True, applying A-weighting
        to the spectrogram before plotting.

        Args:
            plot_type: str, default="spectrogram". Type of plot to create.
            ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
            **kwargs: dict. Additional keyword arguments passed to plot().
                Accepts all parameters from plot() except Aw (which is set to True).

        Returns:
            Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.

        Examples:
            >>> stft = cf.stft()
            >>> # A-weighted spectrogram with custom settings
            >>> stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")
        """
        return self.plot(plot_type=plot_type, ax=ax, Aw=True, **kwargs)

    @recipe_operation("wandas.spectrogram.cepstrum")
    def cepstrum(self, floor: float = 1e-12) -> "CepstrogramFrame":
        """Calculate a real cepstrum independently at every time frame.

        Args:
            floor: float, default=1e-12. Positive finite floor applied to normalized STFT magnitude before
                taking the logarithm.

        Returns:
            CepstrogramFrame: New lazy coefficients shaped ``(channel, quefrency, time)``. The
                source FFT size, hop length, window state, channels, metadata, and
                source-time offsets are preserved.

        Raises:
            TypeError: If ``floor`` is not a real number.
            ValueError: If ``floor`` is non-positive or non-finite.

        Notes:
            The source ``SpectrogramFrame`` already contains normalized one-sided
            STFT amplitudes. This method computes
            ``irfft(log(max(abs(stft), floor)))`` along its frequency axis without
            recomputing the time-domain STFT. It only builds a Dask graph.

        Examples:
            >>> cepstrogram = frame.stft(n_fft=2048).cepstrum()
            >>> envelope = cepstrogram.lifter(0.002).to_spectral_envelope()
        """
        from wandas.frames.cepstrogram import CepstrogramFrame
        from wandas.processing import SpectrogramCepstrum, create_operation

        operation = cast(
            "SpectrogramCepstrum",
            create_operation(
                "spectrogram_cepstrum",
                self.sampling_rate,
                n_fft=self.n_fft,
                floor=floor,
            ),
        )
        return CepstrogramFrame(
            data=operation.process(self._data),
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=self.window,
            label=f"Cepstrogram of {self.label}",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            previous=self,
            source_time_offset=self.source_time_offset,
            lineage=self._required_semantic_lineage(),
        )

    @recipe_operation("wandas.spectrogram.absolute")
    def abs(self) -> "SpectrogramFrame":
        """
        Compute the absolute value (magnitude) of the complex spectrogram.

        This method calculates the magnitude of each complex value in the
        spectrogram, converting the complex-valued data to real-valued magnitude data.
        The result remains a SpectrogramFrame but carries a real numeric dtype.

        Returns:
            SpectrogramFrame: A new SpectrogramFrame containing real-valued magnitudes.

        Examples:
            >>> signal = ChannelFrame.from_wav("audio.wav")
            >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
            >>> magnitude_spectrogram = spectrogram.abs()
            >>> # The magnitude can be accessed via the magnitude property or data
            >>> print(magnitude_spectrogram.magnitude.shape)
        """
        logger.debug("Computing absolute value (magnitude) of spectrogram")

        new_metadata = self._updated_metadata("abs", {})
        from wandas.processing import create_operation

        operation = create_operation("abs", self.sampling_rate)
        magnitude_data = operation.process(self._data)

        logger.debug("Created new SpectrogramFrame with abs operation added to graph")

        return self._create_new_instance(
            data=magnitude_data,
            label=f"abs({self.label})",
            metadata=new_metadata,
            lineage=self._required_semantic_lineage(),
        )

    @recipe_operation("wandas.spectrogram.get_frame_at")
    def get_frame_at(self, time_idx: int) -> "SpectralFrame":
        """
        Extract spectral data at a specific time frame.

        Args:
            time_idx: int. Index of the time frame to extract.

        Returns:
            SpectralFrame: A new SpectralFrame containing the spectral data at the specified time.

        Raises:
            IndexError: If time_idx is out of range.
        """
        from wandas.frames.spectral import SpectralFrame

        if time_idx < 0 or time_idx >= self.n_frames:
            raise IndexError(
                f"Time index out of range\n"
                f"  Got: {time_idx}\n"
                f"  Expected: 0 to {self.n_frames - 1}\n"
                f"Use an index within the valid range for this spectrogram."
            )

        frame_data = self._data[..., time_idx]

        lineage = self._required_semantic_lineage()
        return SpectralFrame(
            data=frame_data,
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            window=self.window,
            label=f"{self.label} (Frame {time_idx}, Time {self.times[time_idx]:.3f}s)",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            source_time_offset=self.source_time_offset + float(self.times[time_idx]),
            lineage=lineage,
            previous=self,
        )

    @recipe_operation("wandas.spectrogram.to_channel_frame")
    def to_channel_frame(self) -> "ChannelFrame":
        """
        Convert the spectrogram back to time domain using inverse STFT.

        This method performs an inverse Short-Time Fourier Transform (ISTFT) to
        reconstruct the time-domain signal from the spectrogram.

        Returns:
            ChannelFrame: A new ChannelFrame containing the reconstructed time-domain signal.

        See Also:
            istft : Alias for this method with more intuitive naming.
        """
        from wandas.frames.channel import ChannelFrame
        from wandas.processing import ISTFT, create_operation

        params = {
            "n_fft": self.n_fft,
            "hop_length": self.hop_length,
            "win_length": self.win_length,
            "window": self.window,
        }
        operation_name = "istft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("ISTFT", operation)
        # Apply processing to data
        time_series = operation.process(self._data)

        logger.debug(f"Created new ChannelFrame with operation {operation_name} added to graph")

        # Create new instance
        lineage = self._required_semantic_lineage()
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"istft({self.label})",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            source_time_offset=self.source_time_offset,
            lineage=lineage,
            previous=self,
        )

    def istft(self) -> "ChannelFrame":
        """
        Convert the spectrogram back to time domain using inverse STFT.

        This is an alias for `to_channel_frame()` with a more intuitive name.
        It performs an inverse Short-Time Fourier Transform (ISTFT) to
        reconstruct the time-domain signal from the spectrogram.

        Returns:
            ChannelFrame: A new ChannelFrame containing the reconstructed time-domain signal.

        See Also:
            to_channel_frame : The underlying implementation.

        Examples:
            >>> signal = ChannelFrame.from_wav("audio.wav")
            >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
            >>> reconstructed = spectrogram.istft()
        """
        return self.to_channel_frame()

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Get additional initialization arguments for SpectrogramFrame.

        This internal method provides the additional initialization arguments
        required by SpectrogramFrame beyond those required by BaseFrame.

        Returns:
            dict[str, Any]: Additional initialization arguments.
        """
        return {
            "n_fft": self.n_fft,
            "hop_length": self.hop_length,
            "win_length": self.win_length,
            "window": self.window,
        }

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """DataFrame index is not supported for SpectrogramFrame."""
        raise NotImplementedError("DataFrame index is not supported for SpectrogramFrame.")

    def to_dataframe(self) -> "pd.DataFrame":
        """DataFrame conversion is not supported for SpectrogramFrame.

        SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames)
        which cannot be directly converted to a 2D DataFrame. Consider using
        get_frame_at() to extract a specific time frame as a SpectralFrame,
        then convert that to a DataFrame.

        Raises:
            NotImplementedError: Always raised as DataFrame conversion is not supported.
        """
        raise NotImplementedError(
            "DataFrame conversion is not supported for SpectrogramFrame. "
            "Use get_frame_at() to extract a specific time frame as SpectralFrame, "
            "then convert that to a DataFrame."
        )

    def info(self) -> None:
        """Display comprehensive information about the SpectrogramFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - FFT size
        - Hop length
        - Window length
        - Window function
        - Frequency range
        - Number of frequency bins
        - Frequency resolution (ΔF)
        - Number of time frames
        - Time resolution (ΔT)
        - Total duration
        - Channel labels
        - Number of operations applied

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples:
            >>> signal = ChannelFrame.from_wav("audio.wav")
            >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
            >>> spectrogram.info()
            SpectrogramFrame Information:
              Channels: 2
              Sampling rate: 44100 Hz
              FFT size: 2048
              Hop length: 512 samples
              Window length: 2048 samples
              Window: hann
              Frequency range: 0.0 - 22050.0 Hz
              Frequency bins: 1025
              Frequency resolution (ΔF): 21.5 Hz
              Time frames: 100
              Time resolution (ΔT): 11.6 ms
              Total duration: 1.16 s
              Channel labels: ['ch0', 'ch1']
              Operations Applied: 1
        """
        # Calculate frequency resolution (ΔF) and time resolution (ΔT)
        delta_f = self.sampling_rate / self.n_fft
        delta_t_ms = (self.hop_length / self.sampling_rate) * 1000
        total_duration = (self.n_frames * self.hop_length) / self.sampling_rate

        print("SpectrogramFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  FFT size: {self.n_fft}")
        print(f"  Hop length: {self.hop_length} samples")
        print(f"  Window length: {self.win_length} samples")
        print(f"  Window: {self.window}")
        print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
        print(f"  Frequency bins: {self.n_freq_bins}")
        print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
        print(f"  Time frames: {self.n_frames}")
        print(f"  Time resolution (ΔT): {delta_t_ms:.1f} ms")
        print(f"  Total duration: {total_duration:.2f} s")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayComplex,
        sampling_rate: float,
        n_fft: int,
        hop_length: int,
        win_length: int | None = None,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        lineage: Any | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: "BaseFrame[Any] | None" = None,
    ) -> "SpectrogramFrame":
        """Create a SpectrogramFrame from a NumPy array.

        Args:
            data: NumPy array containing spectrogram data.
                Shape should be (n_channels, n_freq_bins, n_time_frames) or
                (n_freq_bins, n_time_frames) for single channel.
            sampling_rate: The sampling rate in Hz.
            n_fft: The FFT size used to generate this spectrogram.
            hop_length: Number of samples between successive frames.
            win_length: The window length in samples. If None, defaults to n_fft.
            window: The window function used (e.g., "hann", "hamming").
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            lineage: Runtime operation lineage for this frame.
            channel_metadata: Metadata for each channel.
            previous: Immediate receiver Frame for process-local data comparison.
                For multi-input operations, follows only the left/base receiver.
                Not persisted in WDF.

        Returns:
            A new SpectrogramFrame containing the NumPy data.
        """

        # Normalize shape: support 2D single-channel inputs by expanding
        # to channel-first 3D shape. Reject 1D inputs as invalid for
        # spectrograms.
        if data.ndim not in (2, 3):
            raise ValueError(
                f"Invalid data shape\n"
                f"  Got: {data.shape}\n"
                f"  Expected: 2D (freq, time) or 3D (channel, freq, time) array\n"
                f"Provide a 2D or 3D array to represent time-frequency data."
            )
        if data.ndim == 2:
            data = np.expand_dims(data, axis=0)

        # Convert NumPy array to dask array
        # Use channel-wise chunking for spectrograms (1, -1, -1).
        # Use shared helper to avoid chunking typing issues
        from wandas.utils.dask_helpers import da_from_array as _da_from_array

        dask_data = _da_from_array(data, chunks=(1, -1, -1))
        sf = cls(
            data=dask_data,
            sampling_rate=sampling_rate,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            label=label or "numpy_spectrogram",
            metadata=metadata,
            lineage=lineage,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
        )
        return sf

Attributes

n_fft = n_fft instance-attribute

hop_length = hop_length instance-attribute

win_length = resolved_win_length instance-attribute

window = window instance-attribute

n_frames property

Get the number of time frames.

Returns:

Name Type Description
int int

The number of time frames in the spectrogram.

n_freq_bins property

Get the number of frequency bins.

Returns:

Name Type Description
int int

The number of frequency bins (n_fft // 2 + 1).

freqs property

Get the frequency axis values in Hz.

Values are derived on access from sampling_rate and n_fft using the canonical one-sided real-FFT grid.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Array of frequency values corresponding to each frequency bin.

times property

Get the time axis values in seconds.

This is a zero-based local axis derived from hop_length and sampling_rate. Absolute placement belongs to source_time_offset.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Array of time values corresponding to each time frame.

source_times property

Get frame times relative to the original source timeline.

Functions

__init__(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, lineage=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, operation_history_prefix=())

Initialize a complete canonical one-sided spectrogram.

See the class docstring for parameter descriptions. Frequency and local time axes are derived from the analysis parameters rather than stored as mutable coordinate state.

Source code in wandas/frames/spectrogram.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    hop_length: int,
    win_length: int | None = None,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    lineage: Any | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: "BaseFrame[Any] | None" = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    """Initialize a complete canonical one-sided spectrogram.

    See the class docstring for parameter descriptions. Frequency and local time
    axes are derived from the analysis parameters rather than stored as mutable
    coordinate state.
    """
    if data.ndim == 2:
        data = da.expand_dims(data, axis=0)
    elif data.ndim != 3:
        raise ValueError(
            f"Invalid data dimensions\n"
            f"  Got: {data.ndim}D array with shape {data.shape}\n"
            f"  Expected: 2D or 3D array\n"
            f"Spectrograms require 2D (freq x time) or "
            f"3D (channel x freq x time) data."
        )
    if n_fft <= 0:
        raise ValueError(f"n_fft must be positive, got {n_fft}")
    if hop_length <= 0:
        raise ValueError(f"hop_length must be positive, got {hop_length}")
    resolved_win_length = n_fft if win_length is None else win_length
    if resolved_win_length <= 0:
        raise ValueError(f"win_length must be positive, got {resolved_win_length}")
    if resolved_win_length > n_fft:
        raise ValueError(
            "Invalid win_length for SpectrogramFrame\n"
            f"  Got: {resolved_win_length} for n_fft={n_fft}\n"
            "  Expected: win_length <= n_fft\n"
            "Use the analysis state of the source signal."
        )
    if hop_length > resolved_win_length:
        raise ValueError(
            "Invalid hop_length for SpectrogramFrame\n"
            f"  Got: {hop_length} for win_length={resolved_win_length}\n"
            "  Expected: hop_length <= win_length\n"
            "Use the analysis state of the source signal."
        )
    expected_bins = n_fft // 2 + 1
    if int(data.shape[-2]) != expected_bins:
        raise ValueError(
            f"Invalid frequency bin count\n"
            f"  Got: {data.shape[-2]} bins\n"
            f"  Expected: {expected_bins} bins (n_fft={n_fft})\n"
            "Use the complete canonical one-sided spectrogram."
        )

    self.n_fft = n_fft
    self.hop_length = hop_length
    self.win_length = resolved_win_length
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
        previous=previous,
    )

plot(plot_type='spectrogram', ax=None, title=None, cmap='jet', vmin=None, vmax=None, fmin=0, fmax=None, xlim=None, ylim=None, Aw=False, overlay=False, **kwargs)

Plot the spectrogram using various visualization strategies.

Parameters:

Name Type Description Default
plot_type str

str, default="spectrogram". Type of plot to create.

'spectrogram'
ax Axes | None

matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.

None
title str | None

str, optional. Title for the plot. If None, uses the frame label.

None
cmap str

str, default="jet". Colormap name for the spectrogram visualization.

'jet'
vmin float | None

float, optional. Minimum value for colormap scaling (dB). Auto-calculated if None.

None
vmax float | None

float, optional. Maximum value for colormap scaling (dB). Auto-calculated if None.

None
fmin float

float, default=0. Minimum frequency to display (Hz).

0
fmax float | None

float, optional. Maximum frequency to display (Hz). If None, uses Nyquist frequency.

None
xlim tuple[float, float] | None

tuple[float, float], optional. Time axis limits as (start_time, end_time) in seconds.

None
ylim tuple[float, float] | None

tuple[float, float], optional. Frequency axis limits as (min_freq, max_freq) in Hz.

None
Aw bool

bool, default=False. Whether to apply A-weighting to the spectrogram.

False
overlay bool

bool, default=False. Whether to overlay channels on a single axes.

False
**kwargs Any

dict. Additional keyword arguments passed to Matplotlib plotting methods.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples:

>>> stft = cf.stft()
>>> # Basic spectrogram
>>> stft.plot()
>>> # Custom color scale and frequency range
>>> stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)
>>> # A-weighted spectrogram
>>> stft.plot(Aw=True, cmap="viridis")
Source code in wandas/frames/spectrogram.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def plot(
    self,
    plot_type: str = "spectrogram",
    ax: "Axes | None" = None,
    title: str | None = None,
    cmap: str = "jet",
    vmin: float | None = None,
    vmax: float | None = None,
    fmin: float = 0,
    fmax: float | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    overlay: bool = False,
    **kwargs: Any,
) -> "Axes | Iterator[Axes]":
    """
    Plot the spectrogram using various visualization strategies.

    Args:
        plot_type: str, default="spectrogram". Type of plot to create.
        ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
        title: str, optional. Title for the plot. If None, uses the frame label.
        cmap: str, default="jet". Colormap name for the spectrogram visualization.
        vmin: float, optional. Minimum value for colormap scaling (dB). Auto-calculated if None.
        vmax: float, optional. Maximum value for colormap scaling (dB). Auto-calculated if None.
        fmin: float, default=0. Minimum frequency to display (Hz).
        fmax: float, optional. Maximum frequency to display (Hz). If None, uses Nyquist frequency.
        xlim: tuple[float, float], optional. Time axis limits as (start_time, end_time) in seconds.
        ylim: tuple[float, float], optional. Frequency axis limits as (min_freq, max_freq) in Hz.
        Aw: bool, default=False. Whether to apply A-weighting to the spectrogram.
        overlay: bool, default=False. Whether to overlay channels on a single axes.
        **kwargs: dict. Additional keyword arguments passed to Matplotlib plotting methods.

    Returns:
        Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

    Examples:
        >>> stft = cf.stft()
        >>> # Basic spectrogram
        >>> stft.plot()
        >>> # Custom color scale and frequency range
        >>> stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)
        >>> # A-weighted spectrogram
        >>> stft.plot(Aw=True, cmap="viridis")
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[SpectrogramFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "cmap": cmap,
        "vmin": vmin,
        "vmax": vmax,
        "fmin": fmin,
        "fmax": fmax,
        "Aw": Aw,
        **kwargs,
    }
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, overlay=overlay, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax

plot_Aw(plot_type='spectrogram', ax=None, **kwargs)

Plot the A-weighted spectrogram.

A convenience method that calls plot() with Aw=True, applying A-weighting to the spectrogram before plotting.

Parameters:

Name Type Description Default
plot_type str

str, default="spectrogram". Type of plot to create.

'spectrogram'
ax Axes | None

matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.

None
**kwargs Any

dict. Additional keyword arguments passed to plot(). Accepts all parameters from plot() except Aw (which is set to True).

{}

Returns:

Type Description
Axes | Iterator[Axes]

Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.

Examples:

>>> stft = cf.stft()
>>> # A-weighted spectrogram with custom settings
>>> stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")
Source code in wandas/frames/spectrogram.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def plot_Aw(  # noqa: N802
    self,
    plot_type: str = "spectrogram",
    ax: "Axes | None" = None,
    **kwargs: Any,
) -> "Axes | Iterator[Axes]":
    """
    Plot the A-weighted spectrogram.

    A convenience method that calls plot() with Aw=True, applying A-weighting
    to the spectrogram before plotting.

    Args:
        plot_type: str, default="spectrogram". Type of plot to create.
        ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
        **kwargs: dict. Additional keyword arguments passed to plot().
            Accepts all parameters from plot() except Aw (which is set to True).

    Returns:
        Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot.

    Examples:
        >>> stft = cf.stft()
        >>> # A-weighted spectrogram with custom settings
        >>> stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")
    """
    return self.plot(plot_type=plot_type, ax=ax, Aw=True, **kwargs)

cepstrum(floor=1e-12)

Calculate a real cepstrum independently at every time frame.

Parameters:

Name Type Description Default
floor float

float, default=1e-12. Positive finite floor applied to normalized STFT magnitude before taking the logarithm.

1e-12

Returns:

Name Type Description
CepstrogramFrame CepstrogramFrame

New lazy coefficients shaped (channel, quefrency, time). The source FFT size, hop length, window state, channels, metadata, and source-time offsets are preserved.

Raises:

Type Description
TypeError

If floor is not a real number.

ValueError

If floor is non-positive or non-finite.

Notes

The source SpectrogramFrame already contains normalized one-sided STFT amplitudes. This method computes irfft(log(max(abs(stft), floor))) along its frequency axis without recomputing the time-domain STFT. It only builds a Dask graph.

Examples:

>>> cepstrogram = frame.stft(n_fft=2048).cepstrum()
>>> envelope = cepstrogram.lifter(0.002).to_spectral_envelope()
Source code in wandas/frames/spectrogram.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
@recipe_operation("wandas.spectrogram.cepstrum")
def cepstrum(self, floor: float = 1e-12) -> "CepstrogramFrame":
    """Calculate a real cepstrum independently at every time frame.

    Args:
        floor: float, default=1e-12. Positive finite floor applied to normalized STFT magnitude before
            taking the logarithm.

    Returns:
        CepstrogramFrame: New lazy coefficients shaped ``(channel, quefrency, time)``. The
            source FFT size, hop length, window state, channels, metadata, and
            source-time offsets are preserved.

    Raises:
        TypeError: If ``floor`` is not a real number.
        ValueError: If ``floor`` is non-positive or non-finite.

    Notes:
        The source ``SpectrogramFrame`` already contains normalized one-sided
        STFT amplitudes. This method computes
        ``irfft(log(max(abs(stft), floor)))`` along its frequency axis without
        recomputing the time-domain STFT. It only builds a Dask graph.

    Examples:
        >>> cepstrogram = frame.stft(n_fft=2048).cepstrum()
        >>> envelope = cepstrogram.lifter(0.002).to_spectral_envelope()
    """
    from wandas.frames.cepstrogram import CepstrogramFrame
    from wandas.processing import SpectrogramCepstrum, create_operation

    operation = cast(
        "SpectrogramCepstrum",
        create_operation(
            "spectrogram_cepstrum",
            self.sampling_rate,
            n_fft=self.n_fft,
            floor=floor,
        ),
    )
    return CepstrogramFrame(
        data=operation.process(self._data),
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=self.window,
        label=f"Cepstrogram of {self.label}",
        metadata=self.metadata,
        channel_metadata=self._borrowed_channel_metadata_descriptors(),
        channel_ids=self._channel_ids,
        previous=self,
        source_time_offset=self.source_time_offset,
        lineage=self._required_semantic_lineage(),
    )

abs()

Compute the absolute value (magnitude) of the complex spectrogram.

This method calculates the magnitude of each complex value in the spectrogram, converting the complex-valued data to real-valued magnitude data. The result remains a SpectrogramFrame but carries a real numeric dtype.

Returns:

Name Type Description
SpectrogramFrame SpectrogramFrame

A new SpectrogramFrame containing real-valued magnitudes.

Examples:

>>> signal = ChannelFrame.from_wav("audio.wav")
>>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
>>> magnitude_spectrogram = spectrogram.abs()
>>> # The magnitude can be accessed via the magnitude property or data
>>> print(magnitude_spectrogram.magnitude.shape)
Source code in wandas/frames/spectrogram.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
@recipe_operation("wandas.spectrogram.absolute")
def abs(self) -> "SpectrogramFrame":
    """
    Compute the absolute value (magnitude) of the complex spectrogram.

    This method calculates the magnitude of each complex value in the
    spectrogram, converting the complex-valued data to real-valued magnitude data.
    The result remains a SpectrogramFrame but carries a real numeric dtype.

    Returns:
        SpectrogramFrame: A new SpectrogramFrame containing real-valued magnitudes.

    Examples:
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> magnitude_spectrogram = spectrogram.abs()
        >>> # The magnitude can be accessed via the magnitude property or data
        >>> print(magnitude_spectrogram.magnitude.shape)
    """
    logger.debug("Computing absolute value (magnitude) of spectrogram")

    new_metadata = self._updated_metadata("abs", {})
    from wandas.processing import create_operation

    operation = create_operation("abs", self.sampling_rate)
    magnitude_data = operation.process(self._data)

    logger.debug("Created new SpectrogramFrame with abs operation added to graph")

    return self._create_new_instance(
        data=magnitude_data,
        label=f"abs({self.label})",
        metadata=new_metadata,
        lineage=self._required_semantic_lineage(),
    )

get_frame_at(time_idx)

Extract spectral data at a specific time frame.

Parameters:

Name Type Description Default
time_idx int

int. Index of the time frame to extract.

required

Returns:

Name Type Description
SpectralFrame SpectralFrame

A new SpectralFrame containing the spectral data at the specified time.

Raises:

Type Description
IndexError

If time_idx is out of range.

Source code in wandas/frames/spectrogram.py
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
@recipe_operation("wandas.spectrogram.get_frame_at")
def get_frame_at(self, time_idx: int) -> "SpectralFrame":
    """
    Extract spectral data at a specific time frame.

    Args:
        time_idx: int. Index of the time frame to extract.

    Returns:
        SpectralFrame: A new SpectralFrame containing the spectral data at the specified time.

    Raises:
        IndexError: If time_idx is out of range.
    """
    from wandas.frames.spectral import SpectralFrame

    if time_idx < 0 or time_idx >= self.n_frames:
        raise IndexError(
            f"Time index out of range\n"
            f"  Got: {time_idx}\n"
            f"  Expected: 0 to {self.n_frames - 1}\n"
            f"Use an index within the valid range for this spectrogram."
        )

    frame_data = self._data[..., time_idx]

    lineage = self._required_semantic_lineage()
    return SpectralFrame(
        data=frame_data,
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        window=self.window,
        label=f"{self.label} (Frame {time_idx}, Time {self.times[time_idx]:.3f}s)",
        metadata=self.metadata,
        channel_metadata=self._borrowed_channel_metadata_descriptors(),
        channel_ids=self._channel_ids,
        source_time_offset=self.source_time_offset + float(self.times[time_idx]),
        lineage=lineage,
        previous=self,
    )

to_channel_frame()

Convert the spectrogram back to time domain using inverse STFT.

This method performs an inverse Short-Time Fourier Transform (ISTFT) to reconstruct the time-domain signal from the spectrogram.

Returns:

Name Type Description
ChannelFrame ChannelFrame

A new ChannelFrame containing the reconstructed time-domain signal.

See Also

istft : Alias for this method with more intuitive naming.

Source code in wandas/frames/spectrogram.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
@recipe_operation("wandas.spectrogram.to_channel_frame")
def to_channel_frame(self) -> "ChannelFrame":
    """
    Convert the spectrogram back to time domain using inverse STFT.

    This method performs an inverse Short-Time Fourier Transform (ISTFT) to
    reconstruct the time-domain signal from the spectrogram.

    Returns:
        ChannelFrame: A new ChannelFrame containing the reconstructed time-domain signal.

    See Also:
        istft : Alias for this method with more intuitive naming.
    """
    from wandas.frames.channel import ChannelFrame
    from wandas.processing import ISTFT, create_operation

    params = {
        "n_fft": self.n_fft,
        "hop_length": self.hop_length,
        "win_length": self.win_length,
        "window": self.window,
    }
    operation_name = "istft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("ISTFT", operation)
    # Apply processing to data
    time_series = operation.process(self._data)

    logger.debug(f"Created new ChannelFrame with operation {operation_name} added to graph")

    # Create new instance
    lineage = self._required_semantic_lineage()
    return ChannelFrame(
        data=time_series,
        sampling_rate=self.sampling_rate,
        label=f"istft({self.label})",
        metadata=self.metadata,
        channel_metadata=self._borrowed_channel_metadata_descriptors(),
        channel_ids=self._channel_ids,
        source_time_offset=self.source_time_offset,
        lineage=lineage,
        previous=self,
    )

istft()

Convert the spectrogram back to time domain using inverse STFT.

This is an alias for to_channel_frame() with a more intuitive name. It performs an inverse Short-Time Fourier Transform (ISTFT) to reconstruct the time-domain signal from the spectrogram.

Returns:

Name Type Description
ChannelFrame ChannelFrame

A new ChannelFrame containing the reconstructed time-domain signal.

See Also

to_channel_frame : The underlying implementation.

Examples:

>>> signal = ChannelFrame.from_wav("audio.wav")
>>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
>>> reconstructed = spectrogram.istft()
Source code in wandas/frames/spectrogram.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
def istft(self) -> "ChannelFrame":
    """
    Convert the spectrogram back to time domain using inverse STFT.

    This is an alias for `to_channel_frame()` with a more intuitive name.
    It performs an inverse Short-Time Fourier Transform (ISTFT) to
    reconstruct the time-domain signal from the spectrogram.

    Returns:
        ChannelFrame: A new ChannelFrame containing the reconstructed time-domain signal.

    See Also:
        to_channel_frame : The underlying implementation.

    Examples:
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> reconstructed = spectrogram.istft()
    """
    return self.to_channel_frame()

to_dataframe()

DataFrame conversion is not supported for SpectrogramFrame.

SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames) which cannot be directly converted to a 2D DataFrame. Consider using get_frame_at() to extract a specific time frame as a SpectralFrame, then convert that to a DataFrame.

Raises:

Type Description
NotImplementedError

Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/spectrogram.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
def to_dataframe(self) -> "pd.DataFrame":
    """DataFrame conversion is not supported for SpectrogramFrame.

    SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames)
    which cannot be directly converted to a 2D DataFrame. Consider using
    get_frame_at() to extract a specific time frame as a SpectralFrame,
    then convert that to a DataFrame.

    Raises:
        NotImplementedError: Always raised as DataFrame conversion is not supported.
    """
    raise NotImplementedError(
        "DataFrame conversion is not supported for SpectrogramFrame. "
        "Use get_frame_at() to extract a specific time frame as SpectralFrame, "
        "then convert that to a DataFrame."
    )

info()

Display comprehensive information about the SpectrogramFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - FFT size - Hop length - Window length - Window function - Frequency range - Number of frequency bins - Frequency resolution (ΔF) - Number of time frames - Time resolution (ΔT) - Total duration - Channel labels - Number of operations applied

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples:

>>> signal = ChannelFrame.from_wav("audio.wav")
>>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
>>> spectrogram.info()
SpectrogramFrame Information:
  Channels: 2
  Sampling rate: 44100 Hz
  FFT size: 2048
  Hop length: 512 samples
  Window length: 2048 samples
  Window: hann
  Frequency range: 0.0 - 22050.0 Hz
  Frequency bins: 1025
  Frequency resolution (ΔF): 21.5 Hz
  Time frames: 100
  Time resolution (ΔT): 11.6 ms
  Total duration: 1.16 s
  Channel labels: ['ch0', 'ch1']
  Operations Applied: 1
Source code in wandas/frames/spectrogram.py
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def info(self) -> None:
    """Display comprehensive information about the SpectrogramFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - FFT size
    - Hop length
    - Window length
    - Window function
    - Frequency range
    - Number of frequency bins
    - Frequency resolution (ΔF)
    - Number of time frames
    - Time resolution (ΔT)
    - Total duration
    - Channel labels
    - Number of operations applied

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples:
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> spectrogram.info()
        SpectrogramFrame Information:
          Channels: 2
          Sampling rate: 44100 Hz
          FFT size: 2048
          Hop length: 512 samples
          Window length: 2048 samples
          Window: hann
          Frequency range: 0.0 - 22050.0 Hz
          Frequency bins: 1025
          Frequency resolution (ΔF): 21.5 Hz
          Time frames: 100
          Time resolution (ΔT): 11.6 ms
          Total duration: 1.16 s
          Channel labels: ['ch0', 'ch1']
          Operations Applied: 1
    """
    # Calculate frequency resolution (ΔF) and time resolution (ΔT)
    delta_f = self.sampling_rate / self.n_fft
    delta_t_ms = (self.hop_length / self.sampling_rate) * 1000
    total_duration = (self.n_frames * self.hop_length) / self.sampling_rate

    print("SpectrogramFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  FFT size: {self.n_fft}")
    print(f"  Hop length: {self.hop_length} samples")
    print(f"  Window length: {self.win_length} samples")
    print(f"  Window: {self.window}")
    print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
    print(f"  Frequency bins: {self.n_freq_bins}")
    print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
    print(f"  Time frames: {self.n_frames}")
    print(f"  Time resolution (ΔT): {delta_t_ms:.1f} ms")
    print(f"  Total duration: {total_duration:.2f} s")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()

from_numpy(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, lineage=None, channel_metadata=None, channel_ids=None, previous=None) classmethod

Create a SpectrogramFrame from a NumPy array.

Parameters:

Name Type Description Default
data NDArrayComplex

NumPy array containing spectrogram data. Shape should be (n_channels, n_freq_bins, n_time_frames) or (n_freq_bins, n_time_frames) for single channel.

required
sampling_rate float

The sampling rate in Hz.

required
n_fft int

The FFT size used to generate this spectrogram.

required
hop_length int

Number of samples between successive frames.

required
win_length int | None

The window length in samples. If None, defaults to n_fft.

None
window str

The window function used (e.g., "hann", "hamming").

'hann'
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
lineage Any | None

Runtime operation lineage for this frame.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

Metadata for each channel.

None
previous BaseFrame[Any] | None

Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None

Returns:

Type Description
SpectrogramFrame

A new SpectrogramFrame containing the NumPy data.

Source code in wandas/frames/spectrogram.py
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
@classmethod
def from_numpy(
    cls,
    data: NDArrayComplex,
    sampling_rate: float,
    n_fft: int,
    hop_length: int,
    win_length: int | None = None,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    lineage: Any | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: "BaseFrame[Any] | None" = None,
) -> "SpectrogramFrame":
    """Create a SpectrogramFrame from a NumPy array.

    Args:
        data: NumPy array containing spectrogram data.
            Shape should be (n_channels, n_freq_bins, n_time_frames) or
            (n_freq_bins, n_time_frames) for single channel.
        sampling_rate: The sampling rate in Hz.
        n_fft: The FFT size used to generate this spectrogram.
        hop_length: Number of samples between successive frames.
        win_length: The window length in samples. If None, defaults to n_fft.
        window: The window function used (e.g., "hann", "hamming").
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        lineage: Runtime operation lineage for this frame.
        channel_metadata: Metadata for each channel.
        previous: Immediate receiver Frame for process-local data comparison.
            For multi-input operations, follows only the left/base receiver.
            Not persisted in WDF.

    Returns:
        A new SpectrogramFrame containing the NumPy data.
    """

    # Normalize shape: support 2D single-channel inputs by expanding
    # to channel-first 3D shape. Reject 1D inputs as invalid for
    # spectrograms.
    if data.ndim not in (2, 3):
        raise ValueError(
            f"Invalid data shape\n"
            f"  Got: {data.shape}\n"
            f"  Expected: 2D (freq, time) or 3D (channel, freq, time) array\n"
            f"Provide a 2D or 3D array to represent time-frequency data."
        )
    if data.ndim == 2:
        data = np.expand_dims(data, axis=0)

    # Convert NumPy array to dask array
    # Use channel-wise chunking for spectrograms (1, -1, -1).
    # Use shared helper to avoid chunking typing issues
    from wandas.utils.dask_helpers import da_from_array as _da_from_array

    dask_data = _da_from_array(data, chunks=(1, -1, -1))
    sf = cls(
        data=dask_data,
        sampling_rate=sampling_rate,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        label=label or "numpy_spectrogram",
        metadata=metadata,
        lineage=lineage,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
    )
    return sf

wandas.frames.cepstral.CepstralFrame

Bases: BaseFrame[NDArrayReal]

Immutable, lazy real-cepstrum data on a quefrency axis.

Data is rank two with dimensions (channel, quefrency) and a real dtype. n_fft is the circular period of the complete cepstrum; sliced frames keep that period so methods can reject incomplete axes. Quefrency is measured in seconds at spacing 1 / sampling_rate. The sampling rate is immutable because changing it would reinterpret the stored axis.

lifter() preserves this frame family. to_spectral_envelope() returns a :class:~wandas.frames.spectral.SpectralFrame. Both operations remain Dask backed and preserve channel identity, metadata, source-time offsets, and semantic lineage.

Parameters:

Name Type Description Default
data Array

dask.array.Array. Real coefficients shaped (quefrency,) or (channels, quefrency).

required
sampling_rate float

float. Sampling rate in Hz that defines the quefrency-bin spacing.

required
n_fft int

int. Positive FFT size of the complete cepstrum. Sliced data may contain fewer bins but never more than this value.

required
window str

str, default="hann". Window used by the originating cepstrum analysis.

'hann'
label str | None

str, optional. Human-readable frame label.

None
metadata dict[str, Any] | None

dict, optional. User and recording metadata, copied on construction.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

sequence, optional. Metadata aligned with the channel axis.

None
channel_ids list[str] | None

list[str], optional. Stable identifiers aligned with the channel axis.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None
source_time_offset float | Sequence[float] | NDArrayReal

float or sequence, default=0.0. Per-channel source timeline offsets, preserved across domain changes.

0.0
lineage LineageNode | None

LineageNode, optional. Authoritative runtime semantic lineage.

None
operation_history_prefix Sequence[Mapping[str, Any]]

sequence, default=(). Persisted display history for a new source frame.

()

Raises:

Type Description
TypeError

If coefficients are complex, n_fft is not integral, or window is not a non-empty string.

ValueError

If rank, FFT size, or coefficient count violates the domain contract.

Examples:

>>> cepstrum = frame.cepstrum(n_fft=2048)
>>> envelope = cepstrum.lifter(0.002).to_spectral_envelope()
Source code in wandas/frames/cepstral.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
class CepstralFrame(BaseFrame[NDArrayReal]):
    """Immutable, lazy real-cepstrum data on a quefrency axis.

    Data is rank two with dimensions ``(channel, quefrency)`` and a real dtype.
    ``n_fft`` is the circular period of the complete cepstrum; sliced frames keep
    that period so methods can reject incomplete axes. Quefrency is measured in
    seconds at spacing ``1 / sampling_rate``. The sampling rate is immutable
    because changing it would reinterpret the stored axis.

    ``lifter()`` preserves this frame family. ``to_spectral_envelope()`` returns a
    :class:`~wandas.frames.spectral.SpectralFrame`. Both operations remain Dask
    backed and preserve channel identity, metadata, source-time offsets, and
    semantic lineage.

    Args:
        data: dask.array.Array. Real coefficients shaped ``(quefrency,)`` or
            ``(channels, quefrency)``.
        sampling_rate: float. Sampling rate in Hz that defines the quefrency-bin spacing.
        n_fft: int. Positive FFT size of the complete cepstrum. Sliced data may contain fewer
            bins but never more than this value.
        window: str, default="hann". Window used by the originating cepstrum analysis.
        label: str, optional. Human-readable frame label.
        metadata: dict, optional. User and recording metadata, copied on construction.
        channel_metadata: sequence, optional. Metadata aligned with the channel axis.
        channel_ids: list[str], optional. Stable identifiers aligned with the channel axis.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.
        source_time_offset: float or sequence, default=0.0. Per-channel source
            timeline offsets, preserved across domain changes.
        lineage: LineageNode, optional. Authoritative runtime semantic lineage.
        operation_history_prefix: sequence, default=(). Persisted display history for a new source frame.

    Raises:
        TypeError: If coefficients are complex, ``n_fft`` is not integral, or ``window`` is
            not a non-empty string.
        ValueError: If rank, FFT size, or coefficient count violates the domain contract.

    Examples:
        >>> cepstrum = frame.cepstrum(n_fft=2048)
        >>> envelope = cepstrum.lifter(0.002).to_spectral_envelope()
    """

    _xarray_dim_suffix = ("channel", "quefrency")

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: LineageNode | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim != 2:
            raise ValueError(
                "Invalid data shape for CepstralFrame\n"
                f"  Got: {data.shape} ({data.ndim}D)\n"
                "  Expected: (quefrency,) or (channels, quefrency)\n"
                "Reshape higher-dimensional data before construction."
            )
        if np.issubdtype(data.dtype, np.complexfloating):
            raise TypeError(
                "CepstralFrame requires real-valued coefficients\n"
                f"  Got: {data.dtype}\n"
                "  Expected: a real numeric dtype\n"
                "Construct it from ChannelFrame.cepstrum()."
            )
        if isinstance(n_fft, bool) or not isinstance(n_fft, numbers.Integral):
            raise TypeError(
                "Invalid n_fft for CepstralFrame\n"
                f"  Got: {type(n_fft).__name__}\n"
                "  Expected: a positive integer\n"
                "Pass the FFT size used to produce these coefficients."
            )
        normalized_n_fft = int(n_fft)
        if normalized_n_fft <= 0:
            raise ValueError(
                "Invalid n_fft for CepstralFrame\n"
                f"  Got: {normalized_n_fft}\n"
                "  Expected: a positive integer\n"
                "Pass the FFT size used to produce these coefficients."
            )
        if int(data.shape[-1]) > normalized_n_fft:
            raise ValueError(
                "CepstralFrame cannot contain more quefrency bins than n_fft\n"
                f"  Got: {data.shape[-1]} bins for n_fft={normalized_n_fft}\n"
                "  Expected: coefficient count <= n_fft\n"
                "Use the original FFT size or trim the coefficient data."
            )
        if not isinstance(window, str) or not window:
            raise TypeError("CepstralFrame window must be a non-empty string.")

        self._n_fft = normalized_n_fft
        self._window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
        )

    @property
    def n_fft(self) -> int:
        """Return the immutable circular period of the complete cepstrum."""
        return self._n_fft

    @property
    def window(self) -> str:
        """Return the immutable originating analysis-window name."""
        return self._window

    @property
    def sampling_rate(self) -> float:
        """Return the immutable rate that defines the quefrency spacing."""
        return float(self._xr.attrs["sampling_rate"])

    @property
    def quefrencies(self) -> NDArrayReal:
        """Return a defensive copy of the represented quefrency bins in seconds."""
        return np.asarray(self._xr.coords["quefrency"].values, dtype=float).copy()

    def _xarray_coords(self, data: DaArray) -> dict[str, Any]:
        """Build channel and quefrency coordinates without computing data."""
        coords = super()._xarray_coords(data)
        if "quefrency" in self._xarray_dims(data):
            pending_sampling_rate = getattr(self, "_pending_sampling_rate", None)
            sampling_rate = self.sampling_rate if pending_sampling_rate is None else pending_sampling_rate
            coords["quefrency"] = (
                "quefrency",
                np.arange(int(data.shape[-1]), dtype=float) / sampling_rate,
            )
        return coords

    def _create_new_instance(self, data: DaArray, **kwargs: Any) -> CepstralFrame:
        """Recreate the frame and retain represented coordinates when shape permits."""
        result = cast("CepstralFrame", super()._create_new_instance(data=data, **kwargs))
        if int(result._data.shape[-1]) == len(self.quefrencies):
            result._xr = result._xr.assign_coords(quefrency=("quefrency", self.quefrencies))
        return result

    def _handle_multidim_indexing(self, key: tuple[Any, ...]) -> CepstralFrame:
        """Preserve selected quefrency coordinate values during public slicing."""
        result = cast("CepstralFrame", super()._handle_multidim_indexing(key))
        if len(key) > 1:
            selected = np.asarray(self.quefrencies[key[1]], dtype=float)
            result._xr = result._xr.assign_coords(quefrency=("quefrency", selected))
        return result

    def _binary_operand_op(
        self,
        other: Any,
        op: Callable[[Any, Any], Any],
        symbol: str,
        *,
        reverse: bool = False,
    ) -> CepstralFrame:
        """Require matching cepstral domain state for frame-frame arithmetic."""
        if isinstance(other, BaseFrame) and not isinstance(other, CepstralFrame):
            raise TypeError("CepstralFrame arithmetic requires another CepstralFrame or a scalar/array operand.")
        if isinstance(other, CepstralFrame):
            if self.n_fft != other.n_fft:
                raise ValueError(f"Cepstral n_fft mismatch: {self.n_fft} != {other.n_fft}")
            if self.window != other.window:
                raise ValueError(f"Cepstral analysis window mismatch: {self.window!r} != {other.window!r}")
            if not np.array_equal(self.quefrencies, other.quefrencies):
                raise ValueError("Cepstral quefrency coordinates must match exactly.")
        return cast(
            "CepstralFrame",
            super()._binary_operand_op(other, op, symbol, reverse=reverse),
        )

    @recipe_operation("wandas.cepstral.lifter")
    def lifter(
        self,
        cutoff: float,
        mode: Literal["low", "high"] = "low",
    ) -> CepstralFrame:
        """Keep low- or high-quefrency coefficients.

        Args:
            cutoff: float. Positive quefrency boundary in seconds. It must reach at least one
                represented bin and remain below half the complete cepstrum.
            mode: {"low", "high"}, default="low". ``"low"`` keeps the smooth-envelope region; ``"high"`` keeps the
                complementary fine structure.

        Returns:
            CepstralFrame: A new lazy frame with the same axes and metadata.

        Raises:
            ValueError: If this frame has been sliced on the quefrency axis or the lifter
                parameters cannot be represented.

        Notes:
            The method builds a Dask graph and does not compute coefficients.

        Examples:
            >>> smooth = frame.cepstrum().lifter(0.002, mode="low")
        """
        self._require_complete_quefrency_axis("lifter")
        return cast(
            "CepstralFrame",
            self._apply_named_operation("lifter", cutoff=cutoff, mode=mode),
        )

    @recipe_operation("wandas.cepstral.to_spectral_envelope")
    def to_spectral_envelope(self) -> SpectralFrame:
        """Convert a complete real cepstrum to a smooth spectral envelope.

        Returns:
            SpectralFrame: New lazy complex-valued frequency data with zero phase, the original
                ``n_fft`` and window, and preserved metadata and source-time offsets.

        Raises:
            ValueError: If this frame has been sliced on the quefrency axis. Asymmetric
                concrete coefficients raise when the lazy result is computed.

        Notes:
            This method builds a Dask graph. It does not compute the envelope.

        Examples:
            >>> envelope = frame.cepstrum().lifter(0.002).to_spectral_envelope()
        """
        self._require_complete_quefrency_axis("to_spectral_envelope")
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import SpectralEnvelope, create_operation

        operation = cast(
            "SpectralEnvelope",
            create_operation("spectral_envelope", self.sampling_rate),
        )
        return SpectralFrame(
            data=operation.process(self._data),
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            window=self.window,
            label=f"Spectral envelope of {self.label}",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            previous=self,
            source_time_offset=self.source_time_offset,
            lineage=self._required_semantic_lineage(),
        )

    def _require_complete_quefrency_axis(self, operation_name: str) -> None:
        """Reject transforms whose circular coefficient axis was sliced."""
        expected = np.arange(self.n_fft, dtype=float) / self.sampling_rate
        if int(self._data.shape[-1]) != self.n_fft or not np.array_equal(self.quefrencies, expected):
            raise ValueError(
                f"{operation_name} requires a complete, unsliced quefrency axis. "
                "Apply the operation before slicing the CepstralFrame."
            )

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """Return domain state required by ``_create_new_instance``."""
        return {"n_fft": self.n_fft, "window": self.window}

    def _get_dataframe_index(self) -> pd.Index[Any]:
        """Return the represented quefrency bins as the DataFrame index."""
        pd = require_pandas("CepstralFrame.to_dataframe")
        return pd.Index(self.quefrencies, name="quefrency")

    def plot(
        self,
        plot_type: str = "quefrency",
        ax: Axes | None = None,
        *,
        title: str | None = None,
        xlabel: str = "Quefrency [s]",
        ylabel: str = "Real cepstrum",
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Plot real coefficients against quefrency.

        Args:
            plot_type: str, default="quefrency". Only ``"quefrency"`` is supported.
            ax: matplotlib.axes.Axes, optional. Existing axes. A new figure and axes are created when omitted.
            title: str, optional. Plot title; defaults to the frame label.
            xlabel: str. Horizontal axis label.
            ylabel: str. Vertical axis label.
            **kwargs: Any. Keyword arguments passed to ``Axes.plot``.

        Returns:
            matplotlib.axes.Axes: Axes containing one line per channel.

        Raises:
            ValueError: If another plot type is requested.

        Notes:
            Plotting is an explicit compute boundary and materializes coefficients.

        Examples:
            >>> cepstrum.plot()
        """
        if plot_type != "quefrency":
            raise ValueError("CepstralFrame.plot supports only plot_type='quefrency'.")
        import matplotlib.pyplot as plt

        target = ax if ax is not None else plt.subplots()[1]
        values = self._compute()
        for channel_index, label in enumerate(self.labels):
            target.plot(self.quefrencies, values[channel_index], label=label, **kwargs)
        if self.n_channels > 1:
            target.legend()
        target.set(xlabel=xlabel, ylabel=ylabel, title=title or self.label)
        return target

Attributes

n_fft property

Return the immutable circular period of the complete cepstrum.

window property

Return the immutable originating analysis-window name.

sampling_rate property

Return the immutable rate that defines the quefrency spacing.

quefrencies property

Return a defensive copy of the represented quefrency bins in seconds.

Functions

__init__(data, sampling_rate, n_fft, window='hann', label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Source code in wandas/frames/cepstral.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: LineageNode | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim != 2:
        raise ValueError(
            "Invalid data shape for CepstralFrame\n"
            f"  Got: {data.shape} ({data.ndim}D)\n"
            "  Expected: (quefrency,) or (channels, quefrency)\n"
            "Reshape higher-dimensional data before construction."
        )
    if np.issubdtype(data.dtype, np.complexfloating):
        raise TypeError(
            "CepstralFrame requires real-valued coefficients\n"
            f"  Got: {data.dtype}\n"
            "  Expected: a real numeric dtype\n"
            "Construct it from ChannelFrame.cepstrum()."
        )
    if isinstance(n_fft, bool) or not isinstance(n_fft, numbers.Integral):
        raise TypeError(
            "Invalid n_fft for CepstralFrame\n"
            f"  Got: {type(n_fft).__name__}\n"
            "  Expected: a positive integer\n"
            "Pass the FFT size used to produce these coefficients."
        )
    normalized_n_fft = int(n_fft)
    if normalized_n_fft <= 0:
        raise ValueError(
            "Invalid n_fft for CepstralFrame\n"
            f"  Got: {normalized_n_fft}\n"
            "  Expected: a positive integer\n"
            "Pass the FFT size used to produce these coefficients."
        )
    if int(data.shape[-1]) > normalized_n_fft:
        raise ValueError(
            "CepstralFrame cannot contain more quefrency bins than n_fft\n"
            f"  Got: {data.shape[-1]} bins for n_fft={normalized_n_fft}\n"
            "  Expected: coefficient count <= n_fft\n"
            "Use the original FFT size or trim the coefficient data."
        )
    if not isinstance(window, str) or not window:
        raise TypeError("CepstralFrame window must be a non-empty string.")

    self._n_fft = normalized_n_fft
    self._window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
    )

lifter(cutoff, mode='low')

Keep low- or high-quefrency coefficients.

Parameters:

Name Type Description Default
cutoff float

float. Positive quefrency boundary in seconds. It must reach at least one represented bin and remain below half the complete cepstrum.

required
mode Literal['low', 'high']

{"low", "high"}, default="low". "low" keeps the smooth-envelope region; "high" keeps the complementary fine structure.

'low'

Returns:

Name Type Description
CepstralFrame CepstralFrame

A new lazy frame with the same axes and metadata.

Raises:

Type Description
ValueError

If this frame has been sliced on the quefrency axis or the lifter parameters cannot be represented.

Notes

The method builds a Dask graph and does not compute coefficients.

Examples:

>>> smooth = frame.cepstrum().lifter(0.002, mode="low")
Source code in wandas/frames/cepstral.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
@recipe_operation("wandas.cepstral.lifter")
def lifter(
    self,
    cutoff: float,
    mode: Literal["low", "high"] = "low",
) -> CepstralFrame:
    """Keep low- or high-quefrency coefficients.

    Args:
        cutoff: float. Positive quefrency boundary in seconds. It must reach at least one
            represented bin and remain below half the complete cepstrum.
        mode: {"low", "high"}, default="low". ``"low"`` keeps the smooth-envelope region; ``"high"`` keeps the
            complementary fine structure.

    Returns:
        CepstralFrame: A new lazy frame with the same axes and metadata.

    Raises:
        ValueError: If this frame has been sliced on the quefrency axis or the lifter
            parameters cannot be represented.

    Notes:
        The method builds a Dask graph and does not compute coefficients.

    Examples:
        >>> smooth = frame.cepstrum().lifter(0.002, mode="low")
    """
    self._require_complete_quefrency_axis("lifter")
    return cast(
        "CepstralFrame",
        self._apply_named_operation("lifter", cutoff=cutoff, mode=mode),
    )

to_spectral_envelope()

Convert a complete real cepstrum to a smooth spectral envelope.

Returns:

Name Type Description
SpectralFrame SpectralFrame

New lazy complex-valued frequency data with zero phase, the original n_fft and window, and preserved metadata and source-time offsets.

Raises:

Type Description
ValueError

If this frame has been sliced on the quefrency axis. Asymmetric concrete coefficients raise when the lazy result is computed.

Notes

This method builds a Dask graph. It does not compute the envelope.

Examples:

>>> envelope = frame.cepstrum().lifter(0.002).to_spectral_envelope()
Source code in wandas/frames/cepstral.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
@recipe_operation("wandas.cepstral.to_spectral_envelope")
def to_spectral_envelope(self) -> SpectralFrame:
    """Convert a complete real cepstrum to a smooth spectral envelope.

    Returns:
        SpectralFrame: New lazy complex-valued frequency data with zero phase, the original
            ``n_fft`` and window, and preserved metadata and source-time offsets.

    Raises:
        ValueError: If this frame has been sliced on the quefrency axis. Asymmetric
            concrete coefficients raise when the lazy result is computed.

    Notes:
        This method builds a Dask graph. It does not compute the envelope.

    Examples:
        >>> envelope = frame.cepstrum().lifter(0.002).to_spectral_envelope()
    """
    self._require_complete_quefrency_axis("to_spectral_envelope")
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import SpectralEnvelope, create_operation

    operation = cast(
        "SpectralEnvelope",
        create_operation("spectral_envelope", self.sampling_rate),
    )
    return SpectralFrame(
        data=operation.process(self._data),
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        window=self.window,
        label=f"Spectral envelope of {self.label}",
        metadata=self.metadata,
        channel_metadata=self._borrowed_channel_metadata_descriptors(),
        channel_ids=self._channel_ids,
        previous=self,
        source_time_offset=self.source_time_offset,
        lineage=self._required_semantic_lineage(),
    )

plot(plot_type='quefrency', ax=None, *, title=None, xlabel='Quefrency [s]', ylabel='Real cepstrum', **kwargs)

Plot real coefficients against quefrency.

Parameters:

Name Type Description Default
plot_type str

str, default="quefrency". Only "quefrency" is supported.

'quefrency'
ax Axes | None

matplotlib.axes.Axes, optional. Existing axes. A new figure and axes are created when omitted.

None
title str | None

str, optional. Plot title; defaults to the frame label.

None
xlabel str

str. Horizontal axis label.

'Quefrency [s]'
ylabel str

str. Vertical axis label.

'Real cepstrum'
**kwargs Any

Any. Keyword arguments passed to Axes.plot.

{}

Returns:

Type Description
Axes | Iterator[Axes]

matplotlib.axes.Axes: Axes containing one line per channel.

Raises:

Type Description
ValueError

If another plot type is requested.

Notes

Plotting is an explicit compute boundary and materializes coefficients.

Examples:

>>> cepstrum.plot()
Source code in wandas/frames/cepstral.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def plot(
    self,
    plot_type: str = "quefrency",
    ax: Axes | None = None,
    *,
    title: str | None = None,
    xlabel: str = "Quefrency [s]",
    ylabel: str = "Real cepstrum",
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Plot real coefficients against quefrency.

    Args:
        plot_type: str, default="quefrency". Only ``"quefrency"`` is supported.
        ax: matplotlib.axes.Axes, optional. Existing axes. A new figure and axes are created when omitted.
        title: str, optional. Plot title; defaults to the frame label.
        xlabel: str. Horizontal axis label.
        ylabel: str. Vertical axis label.
        **kwargs: Any. Keyword arguments passed to ``Axes.plot``.

    Returns:
        matplotlib.axes.Axes: Axes containing one line per channel.

    Raises:
        ValueError: If another plot type is requested.

    Notes:
        Plotting is an explicit compute boundary and materializes coefficients.

    Examples:
        >>> cepstrum.plot()
    """
    if plot_type != "quefrency":
        raise ValueError("CepstralFrame.plot supports only plot_type='quefrency'.")
    import matplotlib.pyplot as plt

    target = ax if ax is not None else plt.subplots()[1]
    values = self._compute()
    for channel_index, label in enumerate(self.labels):
        target.plot(self.quefrencies, values[channel_index], label=label, **kwargs)
    if self.n_channels > 1:
        target.legend()
    target.set(xlabel=xlabel, ylabel=ylabel, title=title or self.label)
    return target

wandas.frames.cepstrogram.CepstrogramFrame

Bases: BaseFrame[NDArrayReal]

Immutable, lazy real cepstrum evolving over STFT time frames.

Data is rank three with dimensions (channel, quefrency, time). n_fft defines the complete circular quefrency axis, while hop_length defines the spacing of the retained STFT time frames. lifter() preserves this frame family and to_spectral_envelope() returns a :class:~wandas.frames.spectrogram.SpectrogramFrame.

Parameters:

Name Type Description Default
data Array

dask.array.Array. Real coefficients shaped (quefrency, time) or (channels, quefrency, time).

required
sampling_rate float

float. Sampling rate in Hz defining both axis spacings.

required
n_fft int

int. Positive FFT size of the complete cepstrum.

required
hop_length int

int. Positive sample distance between adjacent time frames.

required
win_length int | None

int, optional. Analysis-window length inherited from the source spectrogram. Defaults to n_fft.

None
window str

str, default="hann". Analysis-window name inherited from the source spectrogram.

'hann'
label str | None

str, optional. Human-readable frame label.

None
metadata dict[str, Any] | None

dict, optional. User and recording metadata, copied on construction.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

sequence, optional. Metadata aligned with the channel axis.

None
channel_ids list[str] | None

list[str], optional. Stable identifiers aligned with the channel axis.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None
source_time_offset float | Sequence[float] | NDArrayReal

float or sequence, default=0.0. Per-channel source timeline offsets.

0.0
lineage LineageNode | None

LineageNode, optional. Authoritative runtime semantic lineage.

None
operation_history_prefix Sequence[Mapping[str, Any]]

sequence, default=(). Persisted display history for a new source frame.

()

Raises:

Type Description
TypeError

If coefficients are complex or domain parameters have invalid types.

ValueError

If rank, FFT size, coefficient count, or time-analysis parameters are invalid.

Examples:

>>> cepstrogram = frame.stft(n_fft=2048).cepstrum()
>>> envelope = cepstrogram.lifter(0.002).to_spectral_envelope()
Source code in wandas/frames/cepstrogram.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
class CepstrogramFrame(BaseFrame[NDArrayReal]):
    """Immutable, lazy real cepstrum evolving over STFT time frames.

    Data is rank three with dimensions ``(channel, quefrency, time)``.
    ``n_fft`` defines the complete circular quefrency axis, while
    ``hop_length`` defines the spacing of the retained STFT time frames.
    ``lifter()`` preserves this frame family and
    ``to_spectral_envelope()`` returns a
    :class:`~wandas.frames.spectrogram.SpectrogramFrame`.

    Args:
        data: dask.array.Array. Real coefficients shaped ``(quefrency, time)`` or
            ``(channels, quefrency, time)``.
        sampling_rate: float. Sampling rate in Hz defining both axis spacings.
        n_fft: int. Positive FFT size of the complete cepstrum.
        hop_length: int. Positive sample distance between adjacent time frames.
        win_length: int, optional. Analysis-window length inherited from the source spectrogram. Defaults
            to ``n_fft``.
        window: str, default="hann". Analysis-window name inherited from the source spectrogram.
        label: str, optional. Human-readable frame label.
        metadata: dict, optional. User and recording metadata, copied on construction.
        channel_metadata: sequence, optional. Metadata aligned with the channel axis.
        channel_ids: list[str], optional. Stable identifiers aligned with the channel axis.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.
        source_time_offset: float or sequence, default=0.0. Per-channel source timeline offsets.
        lineage: LineageNode, optional. Authoritative runtime semantic lineage.
        operation_history_prefix: sequence, default=(). Persisted display history for a new source frame.

    Raises:
        TypeError: If coefficients are complex or domain parameters have invalid types.
        ValueError: If rank, FFT size, coefficient count, or time-analysis parameters are
            invalid.

    Examples:
        >>> cepstrogram = frame.stft(n_fft=2048).cepstrum()
        >>> envelope = cepstrogram.lifter(0.002).to_spectral_envelope()
    """

    _xarray_dim_suffix = ("channel", "quefrency", "time")

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        hop_length: int,
        win_length: int | None = None,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: BaseFrame[Any] | None = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: LineageNode | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        if data.ndim == 2:
            data = data.reshape((1, *data.shape))
        elif data.ndim != 3:
            raise ValueError(
                "Invalid data shape for CepstrogramFrame\n"
                f"  Got: {data.shape} ({data.ndim}D)\n"
                "  Expected: (quefrency, time) or (channels, quefrency, time)\n"
                "Reshape data so quefrency precedes the time axis."
            )
        if np.issubdtype(data.dtype, np.complexfloating):
            raise TypeError(
                "CepstrogramFrame requires real-valued coefficients\n"
                f"  Got: {data.dtype}\n"
                "  Expected: a real numeric dtype\n"
                "Construct it from SpectrogramFrame.cepstrum()."
            )

        normalized_n_fft = self._positive_integer(n_fft, name="n_fft")
        if int(data.shape[-2]) > normalized_n_fft:
            raise ValueError(
                "CepstrogramFrame cannot contain more quefrency bins than n_fft\n"
                f"  Got: {data.shape[-2]} bins for n_fft={normalized_n_fft}\n"
                "  Expected: coefficient count <= n_fft\n"
                "Use the FFT size of the source spectrogram or trim the data."
            )
        normalized_hop_length = self._positive_integer(
            hop_length,
            name="hop_length",
        )
        normalized_win_length = (
            normalized_n_fft if win_length is None else self._positive_integer(win_length, name="win_length")
        )
        if normalized_win_length > normalized_n_fft:
            raise ValueError(
                "Invalid win_length for CepstrogramFrame\n"
                f"  Got: {normalized_win_length} for n_fft={normalized_n_fft}\n"
                "  Expected: win_length <= n_fft\n"
                "Use the analysis state of the source spectrogram."
            )
        if normalized_hop_length > normalized_win_length:
            raise ValueError(
                "Invalid hop_length for CepstrogramFrame\n"
                f"  Got: {normalized_hop_length} for win_length={normalized_win_length}\n"
                "  Expected: hop_length <= win_length\n"
                "Use the analysis state of the source spectrogram."
            )
        if not isinstance(window, str) or not window:
            raise TypeError("CepstrogramFrame window must be a non-empty string.")

        self._n_fft = normalized_n_fft
        self._hop_length = normalized_hop_length
        self._win_length = normalized_win_length
        self._window = window
        self._pending_hop_length = normalized_hop_length
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            previous=previous,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
        )
        del self._pending_hop_length

    @staticmethod
    def _positive_integer(value: int, *, name: str) -> int:
        """Return a positive integer domain parameter."""
        if isinstance(value, bool) or not isinstance(value, numbers.Integral):
            raise TypeError(
                f"Invalid {name} for CepstrogramFrame\n"
                f"  Got: {type(value).__name__}\n"
                "  Expected: a positive integer\n"
                "Use the analysis state of the source spectrogram."
            )
        normalized = int(value)
        if normalized <= 0:
            raise ValueError(
                f"Invalid {name} for CepstrogramFrame\n"
                f"  Got: {normalized}\n"
                "  Expected: a positive integer\n"
                "Use the analysis state of the source spectrogram."
            )
        return normalized

    @property
    def n_fft(self) -> int:
        """Return the immutable circular period of the cepstrum."""
        return self._n_fft

    @property
    def hop_length(self) -> int:
        """Return the immutable sample spacing between time frames."""
        return self._hop_length

    @property
    def win_length(self) -> int:
        """Return the immutable originating analysis-window length."""
        return self._win_length

    @property
    def window(self) -> str:
        """Return the immutable originating analysis-window name."""
        return self._window

    @property
    def sampling_rate(self) -> float:
        """Return the immutable rate defining quefrency and time spacing."""
        return float(self._xr.attrs["sampling_rate"])

    @property
    def n_quefrency_bins(self) -> int:
        """Return the represented quefrency-bin count."""
        return int(self._data.shape[-2])

    @property
    def n_frames(self) -> int:
        """Return the time-frame count."""
        return int(self._data.shape[-1])

    @property
    def quefrencies(self) -> NDArrayReal:
        """Return a defensive copy of represented quefrencies in seconds."""
        return np.asarray(self._xr.coords["quefrency"].values, dtype=float).copy()

    @property
    def times(self) -> NDArrayReal:
        """Return a defensive copy of frame times in seconds."""
        return np.asarray(self._xr.coords["time"].values, dtype=float).copy()

    @property
    def source_times(self) -> NDArrayReal:
        """Return frame times on each channel's original source timeline."""
        return self.source_time_offset[:, None] + self.times[None, :]

    def _xarray_coords(self, data: DaArray) -> dict[str, Any]:
        """Build channel, quefrency, and time coordinates without computing."""
        coords = super()._xarray_coords(data)
        dims = self._xarray_dims(data)
        sampling_rate = getattr(self, "_pending_sampling_rate", None)
        if sampling_rate is None:
            sampling_rate = self.sampling_rate
        if "quefrency" in dims:
            coords["quefrency"] = (
                "quefrency",
                np.arange(int(data.shape[-2]), dtype=float) / sampling_rate,
            )
        if "time" in dims:
            hop_length = getattr(self, "_pending_hop_length", self.hop_length)
            coords["time"] = (
                "time",
                np.arange(int(data.shape[-1]), dtype=float) * hop_length / sampling_rate,
            )
        return coords

    def _create_new_instance(self, data: DaArray, **kwargs: Any) -> CepstrogramFrame:
        """Recreate the frame while retaining a sliced quefrency coordinate."""
        result = cast("CepstrogramFrame", super()._create_new_instance(data=data, **kwargs))
        if int(result._data.shape[-2]) == len(self.quefrencies):
            result._xr = result._xr.assign_coords(quefrency=("quefrency", self.quefrencies))
        return result

    def _handle_multidim_indexing(self, key: tuple[Any, ...]) -> CepstrogramFrame:
        """Preserve represented quefrencies while time slices reset locally."""
        result = cast("CepstrogramFrame", super()._handle_multidim_indexing(key))
        if len(key) > 1:
            selected = np.asarray(self.quefrencies[key[1]], dtype=float)
            result._xr = result._xr.assign_coords(quefrency=("quefrency", selected))
        return result

    def _binary_operand_op(
        self,
        other: Any,
        op: Callable[[Any, Any], Any],
        symbol: str,
        *,
        reverse: bool = False,
    ) -> CepstrogramFrame:
        """Require matching time-varying cepstral state for frame arithmetic."""
        if isinstance(other, BaseFrame) and not isinstance(other, CepstrogramFrame):
            raise TypeError("CepstrogramFrame arithmetic requires another CepstrogramFrame or a scalar/array operand.")
        if isinstance(other, CepstrogramFrame):
            domain_state = (
                self.n_fft,
                self.hop_length,
                self.win_length,
                self.window,
            )
            other_domain_state = (
                other.n_fft,
                other.hop_length,
                other.win_length,
                other.window,
            )
            if domain_state != other_domain_state:
                raise ValueError("Cepstrogram analysis state must match exactly.")
            if not np.array_equal(self.quefrencies, other.quefrencies):
                raise ValueError("Cepstrogram quefrency coordinates must match exactly.")
            if not np.array_equal(self.times, other.times):
                raise ValueError("Cepstrogram time coordinates must match exactly.")
        return cast(
            "CepstrogramFrame",
            super()._binary_operand_op(other, op, symbol, reverse=reverse),
        )

    @recipe_operation("wandas.cepstrogram.lifter")
    def lifter(
        self,
        cutoff: float,
        mode: Literal["low", "high"] = "low",
    ) -> CepstrogramFrame:
        """Keep low- or high-quefrency coefficients at every time frame.

        Args:
            cutoff: float. Positive quefrency boundary in seconds. It must reach at least one
                bin and remain below half of the complete cepstrum.
            mode: {"low", "high"}, default="low". ``"low"`` keeps the smooth-envelope region; ``"high"`` keeps the
                complementary fine structure.

        Returns:
            CepstrogramFrame: New lazy coefficients with unchanged time and channel axes.

        Raises:
            ValueError: If the quefrency axis was sliced or the cutoff is not representable.

        Notes:
            This method only builds a Dask graph.
        """
        self._require_complete_quefrency_axis("lifter")
        return cast(
            "CepstrogramFrame",
            self._apply_named_operation(
                "lifter",
                cutoff=cutoff,
                mode=mode,
                axis=-2,
            ),
        )

    @recipe_operation("wandas.cepstrogram.to_spectral_envelope")
    def to_spectral_envelope(self) -> SpectrogramFrame:
        """Reconstruct a smooth magnitude spectrogram with zero phase.

        Returns:
            SpectrogramFrame: New lazy frequency-time data preserving the original STFT analysis
                state, channels, metadata, and source-time offsets.

        Raises:
            ValueError: If the quefrency axis was sliced. Asymmetric concrete coefficients
                raise when the lazy result is computed.

        Notes:
            This method only builds a Dask graph.
        """
        self._require_complete_quefrency_axis("to_spectral_envelope")
        from wandas.frames.spectrogram import SpectrogramFrame
        from wandas.processing import SpectralEnvelope, create_operation

        operation = cast(
            "SpectralEnvelope",
            create_operation("spectral_envelope", self.sampling_rate, axis=-2),
        )
        return SpectrogramFrame(
            data=operation.process(self._data),
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=self.window,
            label=f"Spectral envelope of {self.label}",
            metadata=self.metadata,
            channel_metadata=self._borrowed_channel_metadata_descriptors(),
            channel_ids=self._channel_ids,
            previous=self,
            source_time_offset=self.source_time_offset,
            lineage=self._required_semantic_lineage(),
        )

    def _require_complete_quefrency_axis(self, operation_name: str) -> None:
        """Reject transforms after slicing the circular quefrency axis."""
        expected = np.arange(self.n_fft, dtype=float) / self.sampling_rate
        if self.n_quefrency_bins != self.n_fft or not np.array_equal(
            self.quefrencies,
            expected,
        ):
            raise ValueError(
                f"{operation_name} requires a complete, unsliced quefrency axis. "
                "Apply the operation before slicing the CepstrogramFrame."
            )

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """Return domain state required by ``_create_new_instance``."""
        return {
            "n_fft": self.n_fft,
            "hop_length": self.hop_length,
            "win_length": self.win_length,
            "window": self.window,
        }

    def _get_dataframe_index(self) -> pd.Index[Any]:
        """Reject a lossy 3D-to-2D conversion."""
        raise NotImplementedError("DataFrame conversion is not supported for CepstrogramFrame.")

    def to_dataframe(self) -> pd.DataFrame:
        """Reject conversion because the frame has three semantic dimensions.

        Raises:
            NotImplementedError: Always raised. Materialize selected data when a tabular representation
                is required.
        """
        raise NotImplementedError("DataFrame conversion is not supported for CepstrogramFrame.")

    def plot(
        self,
        plot_type: str = "cepstrogram",
        ax: Axes | None = None,
        *,
        title: str | None = None,
        xlabel: str = "Time [s]",
        ylabel: str = "Quefrency [s]",
        cmap: str = "RdBu_r",
        qmin: float = 0.0,
        qmax: float | None = None,
        vmin: float | None = None,
        vmax: float | None = None,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Plot real coefficients over time and quefrency.

        Args:
            plot_type: str, default="cepstrogram". Only ``"cepstrogram"`` is supported.
            ax: matplotlib.axes.Axes, optional. Existing axes for a single-channel frame. Multi-channel frames
                create one axes per channel when omitted.
            title: str, optional. Plot title prefix; defaults to the frame label.
            xlabel: str. Horizontal axis label.
            ylabel: str. Vertical axis label.
            cmap: str, default="RdBu_r". Matplotlib colormap for signed real coefficients.
            qmin: float, optional. Lower display bound on the quefrency axis in seconds.
            qmax: float, optional. Upper display bound on the quefrency axis in seconds.
            vmin: float, optional. Lower shared color limit. When omitted, a symmetric robust range is
                estimated from the displayed coefficients except the dominant
                zero-quefrency row.
            vmax: float, optional. Upper shared color limit.
            **kwargs: Any. Additional keyword arguments passed to ``Axes.pcolormesh``.

        Returns:
            matplotlib.axes.Axes or Iterator[matplotlib.axes.Axes]: One axes for
                mono data or an iterator for multiple channels.

        Notes:
            Plotting is an explicit compute boundary.
        """
        if plot_type != "cepstrogram":
            raise ValueError("CepstrogramFrame.plot supports only plot_type='cepstrogram'.")
        if ax is not None and self.n_channels != 1:
            raise ValueError(
                "An explicit axes can plot only one CepstrogramFrame channel. "
                "Select a channel first or omit ax to create separate panels."
            )

        import matplotlib.pyplot as plt

        upper = self.quefrencies[-1] if qmax is None else qmax
        represented = (self.quefrencies >= qmin) & (self.quefrencies <= upper)
        if not np.any(represented):
            raise ValueError("The requested quefrency plot range contains no bins.")

        values = self._compute()
        displayed_values = values[:, represented, :]
        scale_values = displayed_values
        if self.quefrencies[represented][0] == 0.0 and displayed_values.shape[-2] > 1:
            scale_values = displayed_values[:, 1:, :]
        finite_values = np.abs(scale_values[np.isfinite(scale_values)])
        if finite_values.size and (vmin is None or vmax is None):
            robust_limit = float(np.percentile(finite_values, _DEFAULT_COLOR_PERCENTILE))
            if robust_limit > 0:
                vmin = -robust_limit if vmin is None else vmin
                vmax = robust_limit if vmax is None else vmax

        if ax is None:
            _, subplot_grid = plt.subplots(
                self.n_channels,
                1,
                squeeze=False,
                sharex=True,
            )
            axes = list(subplot_grid[:, 0])
        else:
            axes = [ax]

        for channel_index, target in enumerate(axes):
            target.pcolormesh(
                self.times,
                self.quefrencies[represented],
                values[channel_index, represented, :],
                shading="auto",
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                **kwargs,
            )
            channel_title = title or self.label
            if self.n_channels > 1:
                channel_title = f"{channel_title}{self.labels[channel_index]}"
            target.set(
                xlabel=xlabel,
                ylabel=ylabel,
                title=channel_title,
            )
        return axes[0] if self.n_channels == 1 else iter(axes)

Attributes

n_fft property

Return the immutable circular period of the cepstrum.

hop_length property

Return the immutable sample spacing between time frames.

win_length property

Return the immutable originating analysis-window length.

window property

Return the immutable originating analysis-window name.

sampling_rate property

Return the immutable rate defining quefrency and time spacing.

n_quefrency_bins property

Return the represented quefrency-bin count.

n_frames property

Return the time-frame count.

quefrencies property

Return a defensive copy of represented quefrencies in seconds.

times property

Return a defensive copy of frame times in seconds.

source_times property

Return frame times on each channel's original source timeline.

Functions

__init__(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Source code in wandas/frames/cepstrogram.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    hop_length: int,
    win_length: int | None = None,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: BaseFrame[Any] | None = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: LineageNode | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    if data.ndim == 2:
        data = data.reshape((1, *data.shape))
    elif data.ndim != 3:
        raise ValueError(
            "Invalid data shape for CepstrogramFrame\n"
            f"  Got: {data.shape} ({data.ndim}D)\n"
            "  Expected: (quefrency, time) or (channels, quefrency, time)\n"
            "Reshape data so quefrency precedes the time axis."
        )
    if np.issubdtype(data.dtype, np.complexfloating):
        raise TypeError(
            "CepstrogramFrame requires real-valued coefficients\n"
            f"  Got: {data.dtype}\n"
            "  Expected: a real numeric dtype\n"
            "Construct it from SpectrogramFrame.cepstrum()."
        )

    normalized_n_fft = self._positive_integer(n_fft, name="n_fft")
    if int(data.shape[-2]) > normalized_n_fft:
        raise ValueError(
            "CepstrogramFrame cannot contain more quefrency bins than n_fft\n"
            f"  Got: {data.shape[-2]} bins for n_fft={normalized_n_fft}\n"
            "  Expected: coefficient count <= n_fft\n"
            "Use the FFT size of the source spectrogram or trim the data."
        )
    normalized_hop_length = self._positive_integer(
        hop_length,
        name="hop_length",
    )
    normalized_win_length = (
        normalized_n_fft if win_length is None else self._positive_integer(win_length, name="win_length")
    )
    if normalized_win_length > normalized_n_fft:
        raise ValueError(
            "Invalid win_length for CepstrogramFrame\n"
            f"  Got: {normalized_win_length} for n_fft={normalized_n_fft}\n"
            "  Expected: win_length <= n_fft\n"
            "Use the analysis state of the source spectrogram."
        )
    if normalized_hop_length > normalized_win_length:
        raise ValueError(
            "Invalid hop_length for CepstrogramFrame\n"
            f"  Got: {normalized_hop_length} for win_length={normalized_win_length}\n"
            "  Expected: hop_length <= win_length\n"
            "Use the analysis state of the source spectrogram."
        )
    if not isinstance(window, str) or not window:
        raise TypeError("CepstrogramFrame window must be a non-empty string.")

    self._n_fft = normalized_n_fft
    self._hop_length = normalized_hop_length
    self._win_length = normalized_win_length
    self._window = window
    self._pending_hop_length = normalized_hop_length
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        previous=previous,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
    )
    del self._pending_hop_length

lifter(cutoff, mode='low')

Keep low- or high-quefrency coefficients at every time frame.

Parameters:

Name Type Description Default
cutoff float

float. Positive quefrency boundary in seconds. It must reach at least one bin and remain below half of the complete cepstrum.

required
mode Literal['low', 'high']

{"low", "high"}, default="low". "low" keeps the smooth-envelope region; "high" keeps the complementary fine structure.

'low'

Returns:

Name Type Description
CepstrogramFrame CepstrogramFrame

New lazy coefficients with unchanged time and channel axes.

Raises:

Type Description
ValueError

If the quefrency axis was sliced or the cutoff is not representable.

Notes

This method only builds a Dask graph.

Source code in wandas/frames/cepstrogram.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
@recipe_operation("wandas.cepstrogram.lifter")
def lifter(
    self,
    cutoff: float,
    mode: Literal["low", "high"] = "low",
) -> CepstrogramFrame:
    """Keep low- or high-quefrency coefficients at every time frame.

    Args:
        cutoff: float. Positive quefrency boundary in seconds. It must reach at least one
            bin and remain below half of the complete cepstrum.
        mode: {"low", "high"}, default="low". ``"low"`` keeps the smooth-envelope region; ``"high"`` keeps the
            complementary fine structure.

    Returns:
        CepstrogramFrame: New lazy coefficients with unchanged time and channel axes.

    Raises:
        ValueError: If the quefrency axis was sliced or the cutoff is not representable.

    Notes:
        This method only builds a Dask graph.
    """
    self._require_complete_quefrency_axis("lifter")
    return cast(
        "CepstrogramFrame",
        self._apply_named_operation(
            "lifter",
            cutoff=cutoff,
            mode=mode,
            axis=-2,
        ),
    )

to_spectral_envelope()

Reconstruct a smooth magnitude spectrogram with zero phase.

Returns:

Name Type Description
SpectrogramFrame SpectrogramFrame

New lazy frequency-time data preserving the original STFT analysis state, channels, metadata, and source-time offsets.

Raises:

Type Description
ValueError

If the quefrency axis was sliced. Asymmetric concrete coefficients raise when the lazy result is computed.

Notes

This method only builds a Dask graph.

Source code in wandas/frames/cepstrogram.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
@recipe_operation("wandas.cepstrogram.to_spectral_envelope")
def to_spectral_envelope(self) -> SpectrogramFrame:
    """Reconstruct a smooth magnitude spectrogram with zero phase.

    Returns:
        SpectrogramFrame: New lazy frequency-time data preserving the original STFT analysis
            state, channels, metadata, and source-time offsets.

    Raises:
        ValueError: If the quefrency axis was sliced. Asymmetric concrete coefficients
            raise when the lazy result is computed.

    Notes:
        This method only builds a Dask graph.
    """
    self._require_complete_quefrency_axis("to_spectral_envelope")
    from wandas.frames.spectrogram import SpectrogramFrame
    from wandas.processing import SpectralEnvelope, create_operation

    operation = cast(
        "SpectralEnvelope",
        create_operation("spectral_envelope", self.sampling_rate, axis=-2),
    )
    return SpectrogramFrame(
        data=operation.process(self._data),
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=self.window,
        label=f"Spectral envelope of {self.label}",
        metadata=self.metadata,
        channel_metadata=self._borrowed_channel_metadata_descriptors(),
        channel_ids=self._channel_ids,
        previous=self,
        source_time_offset=self.source_time_offset,
        lineage=self._required_semantic_lineage(),
    )

to_dataframe()

Reject conversion because the frame has three semantic dimensions.

Raises:

Type Description
NotImplementedError

Always raised. Materialize selected data when a tabular representation is required.

Source code in wandas/frames/cepstrogram.py
392
393
394
395
396
397
398
399
def to_dataframe(self) -> pd.DataFrame:
    """Reject conversion because the frame has three semantic dimensions.

    Raises:
        NotImplementedError: Always raised. Materialize selected data when a tabular representation
            is required.
    """
    raise NotImplementedError("DataFrame conversion is not supported for CepstrogramFrame.")

plot(plot_type='cepstrogram', ax=None, *, title=None, xlabel='Time [s]', ylabel='Quefrency [s]', cmap='RdBu_r', qmin=0.0, qmax=None, vmin=None, vmax=None, **kwargs)

Plot real coefficients over time and quefrency.

Parameters:

Name Type Description Default
plot_type str

str, default="cepstrogram". Only "cepstrogram" is supported.

'cepstrogram'
ax Axes | None

matplotlib.axes.Axes, optional. Existing axes for a single-channel frame. Multi-channel frames create one axes per channel when omitted.

None
title str | None

str, optional. Plot title prefix; defaults to the frame label.

None
xlabel str

str. Horizontal axis label.

'Time [s]'
ylabel str

str. Vertical axis label.

'Quefrency [s]'
cmap str

str, default="RdBu_r". Matplotlib colormap for signed real coefficients.

'RdBu_r'
qmin float

float, optional. Lower display bound on the quefrency axis in seconds.

0.0
qmax float | None

float, optional. Upper display bound on the quefrency axis in seconds.

None
vmin float | None

float, optional. Lower shared color limit. When omitted, a symmetric robust range is estimated from the displayed coefficients except the dominant zero-quefrency row.

None
vmax float | None

float, optional. Upper shared color limit.

None
**kwargs Any

Any. Additional keyword arguments passed to Axes.pcolormesh.

{}

Returns:

Type Description
Axes | Iterator[Axes]

matplotlib.axes.Axes or Iterator[matplotlib.axes.Axes]: One axes for mono data or an iterator for multiple channels.

Notes

Plotting is an explicit compute boundary.

Source code in wandas/frames/cepstrogram.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
def plot(
    self,
    plot_type: str = "cepstrogram",
    ax: Axes | None = None,
    *,
    title: str | None = None,
    xlabel: str = "Time [s]",
    ylabel: str = "Quefrency [s]",
    cmap: str = "RdBu_r",
    qmin: float = 0.0,
    qmax: float | None = None,
    vmin: float | None = None,
    vmax: float | None = None,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Plot real coefficients over time and quefrency.

    Args:
        plot_type: str, default="cepstrogram". Only ``"cepstrogram"`` is supported.
        ax: matplotlib.axes.Axes, optional. Existing axes for a single-channel frame. Multi-channel frames
            create one axes per channel when omitted.
        title: str, optional. Plot title prefix; defaults to the frame label.
        xlabel: str. Horizontal axis label.
        ylabel: str. Vertical axis label.
        cmap: str, default="RdBu_r". Matplotlib colormap for signed real coefficients.
        qmin: float, optional. Lower display bound on the quefrency axis in seconds.
        qmax: float, optional. Upper display bound on the quefrency axis in seconds.
        vmin: float, optional. Lower shared color limit. When omitted, a symmetric robust range is
            estimated from the displayed coefficients except the dominant
            zero-quefrency row.
        vmax: float, optional. Upper shared color limit.
        **kwargs: Any. Additional keyword arguments passed to ``Axes.pcolormesh``.

    Returns:
        matplotlib.axes.Axes or Iterator[matplotlib.axes.Axes]: One axes for
            mono data or an iterator for multiple channels.

    Notes:
        Plotting is an explicit compute boundary.
    """
    if plot_type != "cepstrogram":
        raise ValueError("CepstrogramFrame.plot supports only plot_type='cepstrogram'.")
    if ax is not None and self.n_channels != 1:
        raise ValueError(
            "An explicit axes can plot only one CepstrogramFrame channel. "
            "Select a channel first or omit ax to create separate panels."
        )

    import matplotlib.pyplot as plt

    upper = self.quefrencies[-1] if qmax is None else qmax
    represented = (self.quefrencies >= qmin) & (self.quefrencies <= upper)
    if not np.any(represented):
        raise ValueError("The requested quefrency plot range contains no bins.")

    values = self._compute()
    displayed_values = values[:, represented, :]
    scale_values = displayed_values
    if self.quefrencies[represented][0] == 0.0 and displayed_values.shape[-2] > 1:
        scale_values = displayed_values[:, 1:, :]
    finite_values = np.abs(scale_values[np.isfinite(scale_values)])
    if finite_values.size and (vmin is None or vmax is None):
        robust_limit = float(np.percentile(finite_values, _DEFAULT_COLOR_PERCENTILE))
        if robust_limit > 0:
            vmin = -robust_limit if vmin is None else vmin
            vmax = robust_limit if vmax is None else vmax

    if ax is None:
        _, subplot_grid = plt.subplots(
            self.n_channels,
            1,
            squeeze=False,
            sharex=True,
        )
        axes = list(subplot_grid[:, 0])
    else:
        axes = [ax]

    for channel_index, target in enumerate(axes):
        target.pcolormesh(
            self.times,
            self.quefrencies[represented],
            values[channel_index, represented, :],
            shading="auto",
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            **kwargs,
        )
        channel_title = title or self.label
        if self.n_channels > 1:
            channel_title = f"{channel_title}{self.labels[channel_index]}"
        target.set(
            xlabel=xlabel,
            ylabel=ylabel,
            title=channel_title,
        )
    return axes[0] if self.n_channels == 1 else iter(axes)

wandas.frames.noct.NOctFrame

Bases: BaseFrame[NDArrayReal]

Class for handling N-octave band analysis data.

This class represents frequency data analyzed in fractional octave bands, typically used in acoustic and vibration analysis. It handles real-valued data representing RMS amplitude in each frequency band, following standard acoustical band definitions. Values retain the input channel's physical unit.

Parameters:

Name Type Description Default
data Array

DaArray. The N-octave band data. Must be a dask array with shape: - (channels, frequency_bins) for multi-channel data - (frequency_bins,) for single-channel data, which will be reshaped to (1, frequency_bins)

required
sampling_rate float

float. The sampling rate of the original time-domain signal in Hz.

required
fmin float

float, default=0. Lower frequency bound in Hz.

0
fmax float

float, default=0. Upper frequency bound in Hz.

0
n int

int, default=3. Number of bands per octave (e.g., 3 for third-octave bands).

3
G int

int, default=10. Exact center-frequency ratio convention: 10 selects base 10**(3/10) and 2 selects base 2.

10
fr int

int, default=1000. Reference frequency in Hz, typically 1000 Hz for acoustic analysis.

1000
label str | None

str, optional. A label for the frame.

None
metadata dict[str, Any] | None

dict, optional. Additional metadata for the frame.

None
lineage Any | None

LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is created. operation_history is its public derived projection.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

list[ChannelMetadata], optional. Metadata for each channel in the frame.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None

Attributes:

Name Type Description
freqs NDArrayReal

NDArrayReal. The center frequencies of each band in Hz, calculated according to the standard fractional octave band definitions.

dB NDArrayReal

NDArrayReal. Band RMS amplitude level, 20 * log10(band_rms / channel_ref).

dBA NDArrayReal

NDArrayReal. The A-weighted spectrum in decibels, applying frequency weighting for better correlation with perceived loudness.

fmin float

float. Lower frequency bound in Hz.

fmax float

float. Upper frequency bound in Hz.

n int

int. Number of bands per octave.

G int

int. Exact center-frequency ratio convention.

fr int

int. Reference frequency in Hz.

Examples:

Create an N-octave band spectrum from a time-domain signal:

>>> signal = ChannelFrame.from_wav("audio.wav")
>>> spectrum = signal.noct_spectrum(fmin=20, fmax=20000, n=3)

Plot the N-octave band spectrum:

>>> spectrum.plot()

Plot with A-weighting applied:

>>> spectrum.plot(Aw=True)
Notes
  • Binary operations (addition, multiplication, etc.) are not currently

supported for N-octave band data. - The actual frequency bands are determined by the parameters n, G, and fr according to IEC 61260-1:2014 standard for fractional octave band filters. - The class follows acoustic standards for band definitions and analysis, making it suitable for noise measurements and sound level analysis. - A-weighting is available for better correlation with human hearing perception, following IEC 61672-1:2013.

Source code in wandas/frames/noct.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
class NOctFrame(BaseFrame[NDArrayReal]):
    """
    Class for handling N-octave band analysis data.

    This class represents frequency data analyzed in fractional octave bands,
    typically used in acoustic and vibration analysis. It handles real-valued
    data representing RMS amplitude in each frequency band, following standard
    acoustical band definitions. Values retain the input channel's physical unit.

    Args:
        data: DaArray. The N-octave band data. Must be a dask array with shape:
            - (channels, frequency_bins) for multi-channel data
            - (frequency_bins,) for single-channel data, which will be
            reshaped to (1, frequency_bins)
        sampling_rate: float. The sampling rate of the original time-domain signal in Hz.
        fmin: float, default=0. Lower frequency bound in Hz.
        fmax: float, default=0. Upper frequency bound in Hz.
        n: int, default=3. Number of bands per octave (e.g., 3 for third-octave bands).
        G: int, default=10. Exact center-frequency ratio convention: 10 selects base
            ``10**(3/10)`` and 2 selects base 2.
        fr: int, default=1000. Reference frequency in Hz, typically 1000 Hz for acoustic analysis.
        label: str, optional. A label for the frame.
        metadata: dict, optional. Additional metadata for the frame.
        lineage: LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is
            created. ``operation_history`` is its public derived projection.
        channel_metadata: list[ChannelMetadata], optional. Metadata for each channel in the frame.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.

    Attributes:
        freqs: NDArrayReal. The center frequencies of each band in Hz, calculated according to
            the standard fractional octave band definitions.
        dB: NDArrayReal. Band RMS amplitude level,
            ``20 * log10(band_rms / channel_ref)``.
        dBA: NDArrayReal. The A-weighted spectrum in decibels, applying frequency weighting
            for better correlation with perceived loudness.
        fmin: float. Lower frequency bound in Hz.
        fmax: float. Upper frequency bound in Hz.
        n: int. Number of bands per octave.
        G: int. Exact center-frequency ratio convention.
        fr: int. Reference frequency in Hz.

    Examples:
        Create an N-octave band spectrum from a time-domain signal:
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrum = signal.noct_spectrum(fmin=20, fmax=20000, n=3)

        Plot the N-octave band spectrum:
        >>> spectrum.plot()

        Plot with A-weighting applied:
        >>> spectrum.plot(Aw=True)

    Notes:
        - Binary operations (addition, multiplication, etc.) are not currently
      supported for N-octave band data.
        - The actual frequency bands are determined by the parameters n, G, and fr
      according to IEC 61260-1:2014 standard for fractional octave band filters.
        - The class follows acoustic standards for band definitions and analysis,
      making it suitable for noise measurements and sound level analysis.
        - A-weighting is available for better correlation with human hearing
      perception, following IEC 61672-1:2013.
    """

    _xarray_dim_suffix = ("channel", "band")

    fmin: float
    fmax: float
    n: int
    G: int
    fr: int

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        fmin: float = 0,
        fmax: float = 0,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: "BaseFrame[Any] | None" = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        """
        Initialize a NOctFrame instance.

        Sets up N-octave band analysis parameters and prepares the frame for
        storing band-filtered data. Data shape is validated to ensure compatibility
        with N-octave band analysis.

        See class docstring for parameter descriptions.
        """
        if not np.isfinite(fmin) or fmin < 0:
            raise ValueError(f"fmin must be finite and non-negative, got {fmin}")
        if not np.isfinite(fmax) or fmax < fmin:
            raise ValueError(
                "Invalid frequency bounds for NOctFrame\n"
                f"  Got: fmin={fmin}, fmax={fmax}\n"
                "  Expected: 0 <= fmin <= fmax\n"
                "Use the frequency bounds of the N-octave analysis."
            )
        if n <= 0 or G <= 0 or fr <= 0:
            raise ValueError(f"n, G, and fr must be positive, got n={n}, G={G}, fr={fr}")
        self.n = n
        self.G = G
        self.fr = fr
        self.fmin = fmin
        self.fmax = fmax
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
            previous=previous,
        )

    @property
    def dB(self) -> NDArrayReal:  # noqa: N802
        """
        Get band RMS amplitude levels relative to each channel reference.

        The conversion is
        ``20 * log10(max(band_rms / channel_ref, 1e-12))``. The reference has
        the same physical unit as the band RMS value and is specified by each
        channel's calibration metadata.

        Returns:
            NDArrayReal: The spectrum in decibels. Shape matches the input data shape:
                (channels, frequency_bins).
        """
        return ref_weighted_dB(self.data, self._channel_metadata, self._data.ndim)

    @property
    def dBA(self) -> NDArrayReal:  # noqa: N802
        """
        Get the A-weighted spectrum in decibels.

        A-weighting applies a frequency-dependent weighting filter that approximates
        the human ear's response to different frequencies. This is particularly useful
        for analyzing noise and acoustic measurements as it provides a better
        correlation with perceived loudness.

        The weighting is applied according to IEC 61672-1:2013 standard.

        Returns:
            NDArrayReal: The A-weighted spectrum in decibels. Shape matches the input data shape:
                (channels, frequency_bins).
        """
        # Collect dB reference values from _channel_metadata
        weighted: NDArrayReal = a_weighting_db(frequencies=self.freqs, min_db=None)
        return self.dB + weighted

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the center frequencies of each band in Hz.

        These frequencies are calculated based on the N-octave band parameters
        (n, G, fr) and the frequency bounds (fmin, fmax) according to
        IEC 61260-1:2014 standard for fractional octave band filters.

        Returns:
            NDArrayReal: Array of center frequencies for each frequency band.

        Raises:
            ValueError: If the center frequencies cannot be calculated or the result
                is not a numpy array.
        """
        _, freqs = _center_freq(
            fmax=self.fmax,
            fmin=self.fmin,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        if isinstance(freqs, np.ndarray):
            return freqs
        raise ValueError("freqs is not numpy array.")

    def _binary_op(
        self: S,
        other: S | complex | NDArrayReal | DaArray,
        op: Callable[[DaArray, Any], DaArray],
        symbol: str,
    ) -> S:
        raise NotImplementedError(f"Operation {symbol} is not implemented for NOctFrame.")

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        raise NotImplementedError(f"Operation {operation_name} is not implemented for NOctFrame.")

    def plot(
        self,
        plot_type: str = "noct",
        ax: "Axes | None" = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> "Axes | Iterator[Axes]":
        """
        Plot the N-octave band data using various visualization strategies.

        Supports standard plotting configurations for acoustic analysis,
        including decibel scales and A-weighting.

        Args:
            plot_type: str, default="noct". Type of plot to create. The default "noct" type creates a step plot
                suitable for displaying N-octave band data.
            ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
            title: str, optional. Title for the plot. If None, uses a default title with band specification.
            overlay: bool, default=False. Whether to overlay all channels on a single plot (True)
                or create separate subplots for each channel (False).
            xlabel: str, optional. Label for the x-axis. If None, uses default "Center frequency [Hz]".
            ylabel: str, optional. Label for the y-axis. If None, uses default based on data type.
            alpha: float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).
            xlim: tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.
            ylim: tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.
            Aw: bool, default=False. Whether to apply A-weighting to the data.
            **kwargs: dict. Additional matplotlib Line2D parameters
                (e.g., color, linewidth, linestyle).

        Returns:
            Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
                for multi-plot outputs.

        Examples:
            >>> noct = spectrum.noct(n=3)
            >>> # Basic 1/3-octave plot
            >>> noct.plot()
            >>> # Overlay with A-weighting
            >>> noct.plot(overlay=True, Aw=True)
            >>> # Custom styling
            >>> noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[NOctFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            "Aw": Aw,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Get additional initialization arguments for NOctFrame.

        This internal method provides the additional initialization arguments
        required by NOctFrame beyond those required by BaseFrame. These include
        the N-octave band analysis parameters that define the frequency bands.

        Returns:
            dict[str, Any]: Additional initialization arguments specific to NOctFrame:
                - n: Number of bands per octave
                - G: Exact center-frequency ratio convention
                - fr: Reference frequency
                - fmin: Lower frequency bound
                - fmax: Upper frequency bound
        """
        return {
            "n": self.n,
            "G": self.G,
            "fr": self.fr,
            "fmin": self.fmin,
            "fmax": self.fmax,
        }

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get frequency index for DataFrame."""
        pd = require_pandas("NOctFrame.to_dataframe")
        return pd.Index(self.freqs, name="frequency")

Attributes

n = n instance-attribute

G = G instance-attribute

fr = fr instance-attribute

fmin = fmin instance-attribute

fmax = fmax instance-attribute

dB property

Get band RMS amplitude levels relative to each channel reference.

The conversion is 20 * log10(max(band_rms / channel_ref, 1e-12)). The reference has the same physical unit as the band RMS value and is specified by each channel's calibration metadata.

Returns:

Name Type Description
NDArrayReal NDArrayReal

The spectrum in decibels. Shape matches the input data shape: (channels, frequency_bins).

dBA property

Get the A-weighted spectrum in decibels.

A-weighting applies a frequency-dependent weighting filter that approximates the human ear's response to different frequencies. This is particularly useful for analyzing noise and acoustic measurements as it provides a better correlation with perceived loudness.

The weighting is applied according to IEC 61672-1:2013 standard.

Returns:

Name Type Description
NDArrayReal NDArrayReal

The A-weighted spectrum in decibels. Shape matches the input data shape: (channels, frequency_bins).

freqs property

Get the center frequencies of each band in Hz.

These frequencies are calculated based on the N-octave band parameters (n, G, fr) and the frequency bounds (fmin, fmax) according to IEC 61260-1:2014 standard for fractional octave band filters.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Array of center frequencies for each frequency band.

Raises:

Type Description
ValueError

If the center frequencies cannot be calculated or the result is not a numpy array.

Functions

__init__(data, sampling_rate, fmin=0, fmax=0, n=3, G=10, fr=1000, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Initialize a NOctFrame instance.

Sets up N-octave band analysis parameters and prepares the frame for storing band-filtered data. Data shape is validated to ensure compatibility with N-octave band analysis.

See class docstring for parameter descriptions.

Source code in wandas/frames/noct.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    fmin: float = 0,
    fmax: float = 0,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: "BaseFrame[Any] | None" = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    """
    Initialize a NOctFrame instance.

    Sets up N-octave band analysis parameters and prepares the frame for
    storing band-filtered data. Data shape is validated to ensure compatibility
    with N-octave band analysis.

    See class docstring for parameter descriptions.
    """
    if not np.isfinite(fmin) or fmin < 0:
        raise ValueError(f"fmin must be finite and non-negative, got {fmin}")
    if not np.isfinite(fmax) or fmax < fmin:
        raise ValueError(
            "Invalid frequency bounds for NOctFrame\n"
            f"  Got: fmin={fmin}, fmax={fmax}\n"
            "  Expected: 0 <= fmin <= fmax\n"
            "Use the frequency bounds of the N-octave analysis."
        )
    if n <= 0 or G <= 0 or fr <= 0:
        raise ValueError(f"n, G, and fr must be positive, got n={n}, G={G}, fr={fr}")
    self.n = n
    self.G = G
    self.fr = fr
    self.fmin = fmin
    self.fmax = fmax
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
        previous=previous,
    )

plot(plot_type='noct', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, Aw=False, **kwargs)

Plot the N-octave band data using various visualization strategies.

Supports standard plotting configurations for acoustic analysis, including decibel scales and A-weighting.

Parameters:

Name Type Description Default
plot_type str

str, default="noct". Type of plot to create. The default "noct" type creates a step plot suitable for displaying N-octave band data.

'noct'
ax Axes | None

matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.

None
title str | None

str, optional. Title for the plot. If None, uses a default title with band specification.

None
overlay bool

bool, default=False. Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False).

False
xlabel str | None

str, optional. Label for the x-axis. If None, uses default "Center frequency [Hz]".

None
ylabel str | None

str, optional. Label for the y-axis. If None, uses default based on data type.

None
alpha float

float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).

1.0
xlim tuple[float, float] | None

tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.

None
ylim tuple[float, float] | None

tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.

None
Aw bool

bool, default=False. Whether to apply A-weighting to the data.

False
**kwargs Any

dict. Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle).

{}

Returns:

Type Description
Axes | Iterator[Axes]

Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples:

>>> noct = spectrum.noct(n=3)
>>> # Basic 1/3-octave plot
>>> noct.plot()
>>> # Overlay with A-weighting
>>> noct.plot(overlay=True, Aw=True)
>>> # Custom styling
>>> noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)
Source code in wandas/frames/noct.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def plot(
    self,
    plot_type: str = "noct",
    ax: "Axes | None" = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> "Axes | Iterator[Axes]":
    """
    Plot the N-octave band data using various visualization strategies.

    Supports standard plotting configurations for acoustic analysis,
    including decibel scales and A-weighting.

    Args:
        plot_type: str, default="noct". Type of plot to create. The default "noct" type creates a step plot
            suitable for displaying N-octave band data.
        ax: matplotlib.axes.Axes, optional. Axes to plot on. If None, creates new axes.
        title: str, optional. Title for the plot. If None, uses a default title with band specification.
        overlay: bool, default=False. Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel: str, optional. Label for the x-axis. If None, uses default "Center frequency [Hz]".
        ylabel: str, optional. Label for the y-axis. If None, uses default based on data type.
        alpha: float, default=1.0. Transparency level for the plot lines (0.0 to 1.0).
        xlim: tuple[float, float], optional. Limits for the x-axis as (min, max) tuple.
        ylim: tuple[float, float], optional. Limits for the y-axis as (min, max) tuple.
        Aw: bool, default=False. Whether to apply A-weighting to the data.
        **kwargs: dict. Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).

    Returns:
        Union[Axes, Iterator[Axes]]: The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

    Examples:
        >>> noct = spectrum.noct(n=3)
        >>> # Basic 1/3-octave plot
        >>> noct.plot()
        >>> # Overlay with A-weighting
        >>> noct.plot(overlay=True, Aw=True)
        >>> # Custom styling
        >>> noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[NOctFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        "Aw": Aw,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax

wandas.frames.roughness.RoughnessFrame

Bases: BaseFrame[NDArrayReal]

Frame for detailed roughness analysis with Bark-band information.

This frame contains specific roughness (R_spec) data organized by Bark frequency bands over time, calculated using the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness follows: R = 0.25 * sum(R_spec, axis=bark_bands)

Parameters:

Name Type Description Default
data Array

da.Array. Specific roughness data with shape: - (n_bark_bands, n_time) for mono signals - (n_channels, n_bark_bands, n_time) for multi-channel signals where n_bark_bands is always 47.

required
sampling_rate float

float. Sampling rate of the roughness time series in Hz. For overlap=0.5, this is approximately 10 Hz (100ms hop). For overlap=0.0, this is approximately 5 Hz (200ms hop).

required
bark_axis NDArrayReal

NDArrayReal. Bark frequency axis with 47 values from 0.5 to 23.5 Bark.

required
overlap float

float. Overlap coefficient used in the calculation (0.0 to 1.0).

required
label str | None

str, optional. Frame label. Defaults to "roughness_spec".

None
metadata dict[str, Any] | None

dict, optional. Additional metadata.

None
lineage Any | None

LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is created. operation_history is its public derived projection.

None
channel_metadata Sequence[ChannelMetadata | dict[str, Any]] | None

list[ChannelMetadata], optional. Metadata for each channel.

None
previous BaseFrame[Any] | None

BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For multi-input operations, follows only the left/base receiver. Not persisted in WDF.

None

Attributes:

Name Type Description
bark_axis NDArrayReal

NDArrayReal. Frequency axis in Bark scale.

n_bark_bands int

int. Number of Bark bands (always 47).

n_time_points int

int. Number of time points.

time NDArrayReal

NDArrayReal. Time axis based on sampling rate.

overlap float

float. Overlap coefficient used (0.0 to 1.0).

Examples:

Create a roughness frame from a signal:

>>> import wandas as wd
>>> signal = wd.read("motor.wav")
>>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
>>>
>>> # Plot Bark-Time heatmap
>>> roughness_spec.plot()
>>>
>>> # Find dominant Bark band
>>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
>>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
>>> print(f"Dominant frequency: {dominant_bark:.1f} Bark")
>>>
>>> # Extract specific Bark band
>>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
>>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

The Daniel & Weber (1997) roughness model calculates specific roughness for 47 critical bands (Bark scale) over time, then integrates them to produce the total roughness:

.. math:: R = 0.25 \sum_{i=1}^{47} R'_i

where R'_i is the specific roughness in the i-th Bark band.

References

.. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model". Acta Acustica united with Acustica, 83(1), 113-123.

Source code in wandas/frames/roughness.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
class RoughnessFrame(BaseFrame[NDArrayReal]):
    """
    Frame for detailed roughness analysis with Bark-band information.

    This frame contains specific roughness (R_spec) data organized by
    Bark frequency bands over time, calculated using the Daniel & Weber (1997)
    method.

    The relationship between total roughness and specific roughness follows:
    R = 0.25 * sum(R_spec, axis=bark_bands)

    Args:
        data: da.Array. Specific roughness data with shape:
            - (n_bark_bands, n_time) for mono signals
            - (n_channels, n_bark_bands, n_time) for multi-channel signals
            where n_bark_bands is always 47.
        sampling_rate: float. Sampling rate of the roughness time series in Hz.
            For overlap=0.5, this is approximately 10 Hz (100ms hop).
            For overlap=0.0, this is approximately 5 Hz (200ms hop).
        bark_axis: NDArrayReal. Bark frequency axis with 47 values from 0.5 to 23.5 Bark.
        overlap: float. Overlap coefficient used in the calculation (0.0 to 1.0).
        label: str, optional. Frame label. Defaults to "roughness_spec".
        metadata: dict, optional. Additional metadata.
        lineage: LineageNode, optional. Constructor override for the runtime lineage. When omitted, a source node is
            created. ``operation_history`` is its public derived projection.
        channel_metadata: list[ChannelMetadata], optional. Metadata for each channel.
        previous: BaseFrame, optional. Immediate receiver Frame for process-local data comparison. For
            multi-input operations, follows only the left/base receiver. Not
            persisted in WDF.

    Attributes:
        bark_axis: NDArrayReal. Frequency axis in Bark scale.
        n_bark_bands: int. Number of Bark bands (always 47).
        n_time_points: int. Number of time points.
        time: NDArrayReal. Time axis based on sampling rate.
        overlap: float. Overlap coefficient used (0.0 to 1.0).

    Examples:
        Create a roughness frame from a signal:

        >>> import wandas as wd
        >>> signal = wd.read("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>>
        >>> # Plot Bark-Time heatmap
        >>> roughness_spec.plot()
        >>>
        >>> # Find dominant Bark band
        >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
        >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
        >>> print(f"Dominant frequency: {dominant_bark:.1f} Bark")
        >>>
        >>> # Extract specific Bark band
        >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
        >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

        The Daniel & Weber (1997) roughness model calculates specific roughness
        for 47 critical bands (Bark scale) over time, then integrates them to
        produce the total roughness:

        .. math::
        R = 0.25 \\sum_{i=1}^{47} R'_i

        where R'_i is the specific roughness in the i-th Bark band.

    References:
        .. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
           Implementation of an optimized model". Acta Acustica united with
           Acustica, 83(1), 113-123.
    """

    bark_axis: NDArrayReal
    overlap: float

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        bark_axis: NDArrayReal,
        overlap: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
        channel_ids: list[str] | None = None,
        previous: "BaseFrame[Any] | None" = None,
        source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
        lineage: Any | None = None,
        operation_history_prefix: Sequence[Mapping[str, Any]] = (),
    ) -> None:
        """Initialize a roughness tensor and its exact analysis state.

        See the class docstring for parameter descriptions. The constructor requires
        one finite Bark coordinate for each of the 47 model bands and an overlap in
        the closed interval ``[0.0, 1.0]``.
        """
        # Validate dimensions
        if data.ndim not in (2, 3):
            raise ValueError(f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D")

        # Validate Bark bands
        if data.shape[-2] != 47:
            raise ValueError(f"Expected 47 Bark bands, got {data.shape[-2]} (data shape: {data.shape})")

        normalized_bark_axis = np.asarray(bark_axis)
        if normalized_bark_axis.ndim != 1 or len(normalized_bark_axis) != 47:
            raise ValueError(f"bark_axis must have 47 elements, got shape {normalized_bark_axis.shape}")
        if not np.all(np.isfinite(normalized_bark_axis)):
            raise ValueError("bark_axis must contain 47 finite real numbers")

        # Validate overlap
        if not np.isfinite(overlap) or not 0.0 <= overlap <= 1.0:
            raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

        self.bark_axis = normalized_bark_axis.copy()
        self.overlap = overlap

        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label or "roughness_spec",
            metadata=metadata,
            channel_metadata=channel_metadata,
            channel_ids=channel_ids,
            source_time_offset=source_time_offset,
            lineage=lineage,
            operation_history_prefix=operation_history_prefix,
            previous=previous,
        )

    @property
    def data(self) -> NDArrayReal:
        """
        Returns the computed data without squeezing.

        For RoughnessFrame, even mono signals have 2D shape (47, n_time)
        so we don't squeeze the channel dimension.

        Returns:
            NDArrayReal: Computed data array.
        """
        return self._compute()

    @property
    def n_bark_bands(self) -> int:
        """
        Number of Bark bands.

        Returns:
            int: Always 47 for the Daniel & Weber model.
        """
        return 47

    @property
    def n_time_points(self) -> int:
        """
        Number of time points in the roughness time series.

        Returns:
            int: Number of time frames in the analysis.
        """
        return int(self._data.shape[-1])

    @property
    def time(self) -> NDArrayReal:
        """
        Time axis based on sampling rate.

        Returns:
            NDArrayReal: Time values in seconds for each frame.
        """
        return np.arange(self.n_time_points) / self.sampling_rate

    @property
    def source_time(self) -> NDArrayReal:
        """Return roughness analysis time points on the source timeline."""
        return self.source_time_offset[:, None] + self.time[None, :]

    def _channel_count_from_data(self, data: DaArray) -> int:
        """Return the number of channels for mono or channel-bark-time data."""
        if data.ndim == 2:
            return 1
        return int(data.shape[-3])

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Provide additional initialization arguments for RoughnessFrame.

        Returns:
            dict: Dictionary containing bark_axis and overlap
        """
        return {
            "bark_axis": self.bark_axis,
            "overlap": self.overlap,
        }

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """DataFrame index is not supported for RoughnessFrame."""
        raise NotImplementedError("DataFrame index is not supported for RoughnessFrame.")

    def _source_time_slice_context(self, keys: tuple[Any, ...]) -> tuple[Any, int, float] | None:
        """Roughness time is stored on the last data axis."""
        key_index = self._data.ndim - 2
        if key_index < 0 or key_index >= len(keys):
            return None
        return keys[key_index], self._data.shape[-1], 1.0 / self.sampling_rate

    def to_dataframe(self) -> "pd.DataFrame":
        """DataFrame conversion is not supported for RoughnessFrame.

        RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
        which cannot be directly converted to a 2D DataFrame.

        Raises:
            NotImplementedError: Always raised as DataFrame conversion is not supported.
        """
        raise NotImplementedError("DataFrame conversion is not supported for RoughnessFrame.")

    def _apply_operation_impl(self, operation_name: str, **params: Any) -> "RoughnessFrame":
        raise NotImplementedError(
            f"Operation '{operation_name}' is not supported for RoughnessFrame. "
            "RoughnessFrame is typically a terminal node in the processing chain."
        )

    # RoughnessFrame intentionally narrows BaseFrame's frame-specific plot vocabulary.
    def plot(  # ty: ignore[invalid-method-override]
        self,
        plot_type: Literal["heatmap"] = "heatmap",
        ax: "Axes | None" = None,
        title: str | None = None,
        cmap: str = "viridis",
        vmin: float | None = None,
        vmax: float | None = None,
        xlabel: str = "Time [s]",
        ylabel: str = "Frequency [Bark]",
        colorbar_label: str = "Specific Roughness [Asper/Bark]",
        **kwargs: Any,
    ) -> "Axes":
        """
        Plot Bark-Time-Roughness heatmap.

        For multi-channel signals, the mean across channels is plotted.

        Args:
            plot_type: {"heatmap"}, default="heatmap". Plot strategy. Only the Bark-time heatmap is supported.
            ax: Axes, optional. Matplotlib axes to plot on. If None, a new figure is created.
            title: str, optional. Plot title. If None, a default title is used.
            cmap: str, default="viridis". Colormap name for the heatmap.
            vmin: float, optional. Lower color scale limit. If None, automatic scaling is used.
            vmax: float, optional. Upper color scale limit. If None, automatic scaling is used.
            xlabel: str, default="Time [s]". Label for the x-axis.
            ylabel: str, default="Frequency [Bark]". Label for the y-axis.
            colorbar_label: str, default="Specific Roughness [Asper/Bark]". Label for the colorbar.
            **kwargs: Any. Additional keyword arguments passed to pcolormesh.

        Returns:
            Axes: The matplotlib axes object containing the plot.

        Raises:
            ValueError: If ``plot_type`` is not ``"heatmap"``.

        Notes:
            Plotting is an explicit compute boundary.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("motor.wav")
            >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
            >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
        """
        if plot_type != "heatmap":
            raise ValueError("RoughnessFrame.plot supports only plot_type='heatmap'.")

        plt = require_matplotlib_pyplot("roughness plot")

        if ax is None:
            _, ax = plt.subplots(figsize=(10, 6))

        # Select data to plot (first channel for mono, mean for multi-channel)
        # self._data is Dask array, self.data is computed NumPy array
        computed_data = self._compute()

        # Select data to plot (first channel for mono, mean for multi-channel)
        data_to_plot = computed_data if computed_data.ndim == 2 else computed_data.mean(axis=0)

        # Create heatmap
        im = ax.pcolormesh(
            self.time,
            self.bark_axis,
            data_to_plot,
            shading="auto",
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            **kwargs,
        )

        # Labels and title
        ax.set_xlabel(xlabel)
        ax.set_ylabel(ylabel)
        if title is None:
            title = f"Roughness Spectrogram (overlap={self.overlap})"
        ax.set_title(title)

        # Colorbar
        plt.colorbar(im, ax=ax, label=colorbar_label)

        return ax

Attributes

bark_axis = normalized_bark_axis.copy() instance-attribute

overlap = overlap instance-attribute

data property

Returns the computed data without squeezing.

For RoughnessFrame, even mono signals have 2D shape (47, n_time) so we don't squeeze the channel dimension.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Computed data array.

n_bark_bands property

Number of Bark bands.

Returns:

Name Type Description
int int

Always 47 for the Daniel & Weber model.

n_time_points property

Number of time points in the roughness time series.

Returns:

Name Type Description
int int

Number of time frames in the analysis.

time property

Time axis based on sampling rate.

Returns:

Name Type Description
NDArrayReal NDArrayReal

Time values in seconds for each frame.

source_time property

Return roughness analysis time points on the source timeline.

Functions

__init__(data, sampling_rate, bark_axis, overlap, label=None, metadata=None, channel_metadata=None, channel_ids=None, previous=None, source_time_offset=0.0, lineage=None, operation_history_prefix=())

Initialize a roughness tensor and its exact analysis state.

See the class docstring for parameter descriptions. The constructor requires one finite Bark coordinate for each of the 47 model bands and an overlap in the closed interval [0.0, 1.0].

Source code in wandas/frames/roughness.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    bark_axis: NDArrayReal,
    overlap: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    channel_metadata: Sequence[ChannelMetadata | dict[str, Any]] | None = None,
    channel_ids: list[str] | None = None,
    previous: "BaseFrame[Any] | None" = None,
    source_time_offset: float | Sequence[float] | NDArrayReal = 0.0,
    lineage: Any | None = None,
    operation_history_prefix: Sequence[Mapping[str, Any]] = (),
) -> None:
    """Initialize a roughness tensor and its exact analysis state.

    See the class docstring for parameter descriptions. The constructor requires
    one finite Bark coordinate for each of the 47 model bands and an overlap in
    the closed interval ``[0.0, 1.0]``.
    """
    # Validate dimensions
    if data.ndim not in (2, 3):
        raise ValueError(f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D")

    # Validate Bark bands
    if data.shape[-2] != 47:
        raise ValueError(f"Expected 47 Bark bands, got {data.shape[-2]} (data shape: {data.shape})")

    normalized_bark_axis = np.asarray(bark_axis)
    if normalized_bark_axis.ndim != 1 or len(normalized_bark_axis) != 47:
        raise ValueError(f"bark_axis must have 47 elements, got shape {normalized_bark_axis.shape}")
    if not np.all(np.isfinite(normalized_bark_axis)):
        raise ValueError("bark_axis must contain 47 finite real numbers")

    # Validate overlap
    if not np.isfinite(overlap) or not 0.0 <= overlap <= 1.0:
        raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

    self.bark_axis = normalized_bark_axis.copy()
    self.overlap = overlap

    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label or "roughness_spec",
        metadata=metadata,
        channel_metadata=channel_metadata,
        channel_ids=channel_ids,
        source_time_offset=source_time_offset,
        lineage=lineage,
        operation_history_prefix=operation_history_prefix,
        previous=previous,
    )

to_dataframe()

DataFrame conversion is not supported for RoughnessFrame.

RoughnessFrame contains 3D data (channels, bark_bands, time_frames) which cannot be directly converted to a 2D DataFrame.

Raises:

Type Description
NotImplementedError

Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/roughness.py
228
229
230
231
232
233
234
235
236
237
def to_dataframe(self) -> "pd.DataFrame":
    """DataFrame conversion is not supported for RoughnessFrame.

    RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
    which cannot be directly converted to a 2D DataFrame.

    Raises:
        NotImplementedError: Always raised as DataFrame conversion is not supported.
    """
    raise NotImplementedError("DataFrame conversion is not supported for RoughnessFrame.")

plot(plot_type='heatmap', ax=None, title=None, cmap='viridis', vmin=None, vmax=None, xlabel='Time [s]', ylabel='Frequency [Bark]', colorbar_label='Specific Roughness [Asper/Bark]', **kwargs)

Plot Bark-Time-Roughness heatmap.

For multi-channel signals, the mean across channels is plotted.

Parameters:

Name Type Description Default
plot_type Literal['heatmap']

{"heatmap"}, default="heatmap". Plot strategy. Only the Bark-time heatmap is supported.

'heatmap'
ax Axes | None

Axes, optional. Matplotlib axes to plot on. If None, a new figure is created.

None
title str | None

str, optional. Plot title. If None, a default title is used.

None
cmap str

str, default="viridis". Colormap name for the heatmap.

'viridis'
vmin float | None

float, optional. Lower color scale limit. If None, automatic scaling is used.

None
vmax float | None

float, optional. Upper color scale limit. If None, automatic scaling is used.

None
xlabel str

str, default="Time [s]". Label for the x-axis.

'Time [s]'
ylabel str

str, default="Frequency [Bark]". Label for the y-axis.

'Frequency [Bark]'
colorbar_label str

str, default="Specific Roughness [Asper/Bark]". Label for the colorbar.

'Specific Roughness [Asper/Bark]'
**kwargs Any

Any. Additional keyword arguments passed to pcolormesh.

{}

Returns:

Name Type Description
Axes Axes

The matplotlib axes object containing the plot.

Raises:

Type Description
ValueError

If plot_type is not "heatmap".

Notes

Plotting is an explicit compute boundary.

Examples:

>>> import wandas as wd
>>> signal = wd.read("motor.wav")
>>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
>>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
Source code in wandas/frames/roughness.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def plot(  # ty: ignore[invalid-method-override]
    self,
    plot_type: Literal["heatmap"] = "heatmap",
    ax: "Axes | None" = None,
    title: str | None = None,
    cmap: str = "viridis",
    vmin: float | None = None,
    vmax: float | None = None,
    xlabel: str = "Time [s]",
    ylabel: str = "Frequency [Bark]",
    colorbar_label: str = "Specific Roughness [Asper/Bark]",
    **kwargs: Any,
) -> "Axes":
    """
    Plot Bark-Time-Roughness heatmap.

    For multi-channel signals, the mean across channels is plotted.

    Args:
        plot_type: {"heatmap"}, default="heatmap". Plot strategy. Only the Bark-time heatmap is supported.
        ax: Axes, optional. Matplotlib axes to plot on. If None, a new figure is created.
        title: str, optional. Plot title. If None, a default title is used.
        cmap: str, default="viridis". Colormap name for the heatmap.
        vmin: float, optional. Lower color scale limit. If None, automatic scaling is used.
        vmax: float, optional. Upper color scale limit. If None, automatic scaling is used.
        xlabel: str, default="Time [s]". Label for the x-axis.
        ylabel: str, default="Frequency [Bark]". Label for the y-axis.
        colorbar_label: str, default="Specific Roughness [Asper/Bark]". Label for the colorbar.
        **kwargs: Any. Additional keyword arguments passed to pcolormesh.

    Returns:
        Axes: The matplotlib axes object containing the plot.

    Raises:
        ValueError: If ``plot_type`` is not ``"heatmap"``.

    Notes:
        Plotting is an explicit compute boundary.

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
    """
    if plot_type != "heatmap":
        raise ValueError("RoughnessFrame.plot supports only plot_type='heatmap'.")

    plt = require_matplotlib_pyplot("roughness plot")

    if ax is None:
        _, ax = plt.subplots(figsize=(10, 6))

    # Select data to plot (first channel for mono, mean for multi-channel)
    # self._data is Dask array, self.data is computed NumPy array
    computed_data = self._compute()

    # Select data to plot (first channel for mono, mean for multi-channel)
    data_to_plot = computed_data if computed_data.ndim == 2 else computed_data.mean(axis=0)

    # Create heatmap
    im = ax.pcolormesh(
        self.time,
        self.bark_axis,
        data_to_plot,
        shading="auto",
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
        **kwargs,
    )

    # Labels and title
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title is None:
        title = f"Roughness Spectrogram (overlap={self.overlap})"
    ax.set_title(title)

    # Colorbar
    plt.colorbar(im, ax=ax, label=colorbar_label)

    return ax

wandas.frames.mixins.channel_processing_mixin.ChannelProcessingMixin

Mixin that provides methods related to signal processing.

This mixin provides processing methods applied to audio signals and other time-series data, such as signal processing filters and transformation operations.

Source code in wandas/frames/mixins/channel_processing_mixin.py
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
class ChannelProcessingMixin:
    """Mixin that provides methods related to signal processing.

    This mixin provides processing methods applied to audio signals and
    other time-series data, such as signal processing filters and
    transformation operations.
    """

    def _get_ref_values(
        self: ProcessingFrameProtocol,
        *,
        require_non_default: bool = False,
    ) -> list[float]:
        """Extract per-channel reference values from channel metadata.

        Args:
            require_non_default: bool. When ``True``, return an empty list unless at least one channel
                has a non-empty ``unit`` or a ``ref`` that differs from 1.0.
        """
        if not hasattr(self, "_channel_metadata") or not self._channel_metadata:
            return []
        if require_non_default and not any(ch.unit or ch.ref != 1.0 for ch in self._channel_metadata):
            return []
        return [ch.ref for ch in self._channel_metadata]

    def _apply_level_operation(
        self: ProcessingFrameProtocol,
        operation_name: str,
        **params: Any,
    ) -> Any:
        """Apply a dB operation with reference-bearing output metadata."""
        from wandas.processing import create_operation as create_named_operation

        calibration_scales = [channel.calibration.factor for channel in cast(Any, self).channels]
        operation = create_named_operation(
            operation_name,
            self.sampling_rate,
            _calibration_scale=calibration_scales or 1.0,
            **params,
        )
        display = operation.get_display_name() or operation_name
        channel_metadata = cast(Any, self)._metadata_after_analysis()
        for descriptor, channel in zip(channel_metadata, cast(Any, self).channels, strict=True):
            descriptor["label"] = f"{display}({channel.label})"
            descriptor["calibration"] = ChannelCalibration(
                factor=1.0,
                unit=_format_level_unit(channel.calibration),
                ref=1.0,
            )

        return cast(Any, self)._apply_operation_instance(
            operation,
            operation_name=operation_name,
            frame_metadata_updates={"channel_metadata": channel_metadata},
            process_data=cast(Any, self)._data,
        )

    def _compute_scalar_metric(
        self: ProcessingFrameProtocol,
        operation: Any,
    ) -> "NDArrayReal":
        """Run *operation* per-channel and return a 1-D NumPy result.

        Shared post-processing logic for steady-state metrics that return one
        scalar per channel (e.g. ``loudness_zwst``, ``sharpness_din_st``).
        """
        from wandas.utils.types import NDArrayReal

        ensure_dependencies = getattr(operation, "ensure_dependencies", None)
        if ensure_dependencies is not None:
            ensure_dependencies()

        data = self.data
        if data.ndim == 1:
            data = data.reshape(1, -1)
        result = operation._process(data)
        values: NDArrayReal = result.squeeze()
        if values.ndim == 0:
            values = values.reshape(1)
        return values

    # Overload 1: no domain transition — return type matches caller's frame type.
    @overload
    @_runtime_apply_semantic
    def apply(
        self: T_Processing,
        func: Callable[..., Any],
        output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = ...,
        output_frame_class: None = ...,
        output_frame_kwargs: dict[str, Any] | None = ...,
        *,
        dask_pure: bool = ...,
        **kwargs: Any,
    ) -> T_Processing: ...

    # Overload 2: domain transition — output_frame_class determines the return
    # type statically via T_OutputFrame.
    @overload
    @_runtime_apply_semantic
    def apply(
        self: T_Processing,
        func: Callable[..., Any],
        output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = ...,
        output_frame_class: type[T_OutputFrame] = ...,
        output_frame_kwargs: dict[str, Any] | None = ...,
        *,
        dask_pure: bool = ...,
        **kwargs: Any,
    ) -> T_OutputFrame: ...

    @_runtime_apply_semantic
    def apply(
        self: T_Processing,
        func: Callable[..., Any],
        output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = None,
        output_frame_class: type[T_OutputFrame] | None = None,
        output_frame_kwargs: dict[str, Any] | None = None,
        *,
        dask_pure: bool = True,
        **kwargs: Any,
    ) -> Any:
        """Apply a custom function to the signal.

        Args:
            func: Function to apply.
            output_shape_func: Optional function to calculate output shape.
            output_frame_class: Optional frame class for the output.  When
                provided, the result is wrapped in this class instead of the
                caller's type, enabling domain transitions (e.g.
                ``ChannelFrame`` -> ``SpectralFrame``).
            output_frame_kwargs: Extra constructor keyword arguments required
                by *output_frame_class* (e.g. ``{"n_fft": 1024}``).
            dask_pure: Dask execution-control flag for delayed custom
                operations. Set to ``False`` for non-deterministic or
                side-effecting functions. This value is not forwarded to
                *func* or recorded in operation history.
            **kwargs: Additional arguments for the function.

        Returns:
            New frame with the custom function applied.
        """
        from wandas.processing.custom import CustomOperation

        # Pre-validation: check for parameter name conflicts
        if "sampling_rate" in kwargs:
            raise ValueError(
                "Parameter name conflict\n"
                "  Cannot use 'sampling_rate' as a parameter in apply().\n"
                "  The sampling rate is automatically provided from the frame.\n"
                "  Suggested alternatives: 'sr', 'sample_rate', or 'fs'\n"
                f"  Received params: {list(kwargs.keys())}"
            )
        if "pure" in kwargs:
            raise ValueError(
                "Parameter name conflict\n"
                "  Cannot use 'pure' as a parameter in apply().\n"
                "  'pure' is reserved for operation purity and Dask task semantics.\n"
                "  Suggested alternative: rename the function argument to 'is_pure'.\n"
                f"  Received params: {list(kwargs.keys())}"
            )

        operation = CustomOperation(
            sampling_rate=self.sampling_rate,
            func=func,
            output_shape_func=output_shape_func,
            dask_pure=dask_pure,
            **kwargs,
        )

        return cast(Any, self)._apply_operation_instance(
            operation,
            output_frame_class=output_frame_class,
            output_frame_kwargs=output_frame_kwargs,
        )

    @recipe_operation("wandas.audio.highpass_filter")
    def high_pass_filter(self: T_Processing, cutoff: float, order: int = 4) -> T_Processing:
        """Apply a high-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)")
        result = self._apply_named_operation("highpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.lowpass_filter")
    def low_pass_filter(self: T_Processing, cutoff: float, order: int = 4) -> T_Processing:
        """Apply a low-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)")
        result = self._apply_named_operation("lowpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.bandpass_filter")
    def band_pass_filter(self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4) -> T_Processing:
        """Apply a band-pass filter to the signal.

        Args:
            low_cutoff: Lower cutoff frequency (Hz)
            high_cutoff: Higher cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up bandpass filter: low_cutoff={low_cutoff}, high_cutoff={high_cutoff}, order={order} (lazy)"
        )
        result = self._apply_named_operation(
            "bandpass_filter",
            low_cutoff=low_cutoff,
            high_cutoff=high_cutoff,
            order=order,
        )
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.normalize")
    def normalize(
        self: T_Processing,
        norm: float | None = float("inf"),
        axis: int | None = -1,
        threshold: float | None = None,
        fill: bool | None = None,
    ) -> T_Processing:
        """Normalize signal levels using NumPy-based normalization.

        This method normalizes the signal amplitude according to the specified norm.

        Args:
            norm: Norm type. Default is np.inf (maximum absolute value normalization).
                Supported values:
                - np.inf: Maximum absolute value normalization
                - -np.inf: Minimum absolute value normalization
                - 0: Peak normalization
                - float: Lp norm
                - None: No normalization
            axis: Axis along which to normalize. Default is -1 (time axis).
                - -1: Normalize along time axis (each channel independently)
                - None: Global normalization across all axes
                - int: Normalize along specified axis
            threshold: Threshold below which values are considered zero.
                If None, no threshold is applied.
            fill: Value to fill when the norm is zero.
                If None, the zero vector remains zero.

        Returns:
            New ChannelFrame containing the normalized signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("audio.wav")
            >>> # Normalize to maximum absolute value of 1.0 (per channel)
            >>> normalized = signal.normalize()
            >>> # Global normalization across all channels
            >>> normalized_global = signal.normalize(axis=None)
            >>> # L2 normalization
            >>> normalized_l2 = signal.normalize(norm=2)
        """
        logger.debug(f"Setting up normalize: norm={norm}, axis={axis}, threshold={threshold}, fill={fill} (lazy)")
        result = self._apply_named_operation("normalize", norm=norm, axis=axis, threshold=threshold, fill=fill)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.remove_dc")
    def remove_dc(self: T_Processing) -> T_Processing:
        """Remove DC component (DC offset) from the signal.

        This method removes the DC (direct current) component by subtracting
        the mean value from each channel. This is equivalent to centering the
        signal around zero.

        Returns:
            New ChannelFrame with DC component removed

        Examples:
            >>> import wandas as wd
            >>> import numpy as np
            >>> # Create signal with DC offset
            >>> signal = wd.read("audio.wav")
            >>> signal_with_dc = signal + 2.0  # Add DC offset
            >>> # Remove DC offset
            >>> signal_clean = signal_with_dc.remove_dc()
            >>> # Verify DC removal
            >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

        Notes:
            - This operation is performed per channel
            - Equivalent to applying a high-pass filter with very low cutoff
            - Useful for removing sensor drift or measurement offset
        """
        logger.debug("Setting up DC removal (lazy)")
        result = self._apply_named_operation("remove_dc")
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.a_weighting")
    def a_weighting(self: T_Processing) -> T_Processing:
        """Apply A-weighting filter to the signal.

        A-weighting adjusts the frequency response to approximate human
        auditory perception using the implemented digital curve. This returns
        a weighted linear waveform in the input unit; it does not calculate
        RMS or convert to dB. No sound-level-meter conformance is implied.

        Returns:
            New ChannelFrame containing the A-weighted signal
        """
        result = self._apply_named_operation("a_weighting")
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.abs")
    def abs(self: T_Processing) -> T_Processing:
        """Compute the absolute value of the signal.

        Returns:
            New ChannelFrame containing the absolute values
        """
        result = self._apply_named_operation("abs")
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.power")
    def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
        """Compute the power of the signal.

        Args:
            exponent: Exponent to raise the signal to. Default is 2.0.

        Returns:
            New ChannelFrame containing the powered signal
        """
        result = self._apply_named_operation("power", exponent=exponent)
        return cast(T_Processing, result)

    def _reduce_channels(self: T_Processing, op: str) -> T_Processing:
        """Helper to reduce all channels with the given operation ('sum' or 'mean')."""
        from wandas.processing import create_operation

        if op == "sum":
            label = "sum"
        elif op == "mean":
            label = "mean"
        else:
            raise ValueError(f"Unsupported reduction operation: {op}")
        operation = create_operation(op, self.sampling_rate)
        reduced_data = operation.process(self._effective_data)

        units = [ch.unit for ch in self._channel_metadata]
        reduced_unit = units[0] if all(u == units[0] for u in units) else ""

        reduced_extra = {"source_extras": [ch.extra for ch in self._channel_metadata]}
        new_channel_metadata = [
            ChannelMetadata(
                label=label,
                unit=reduced_unit,
                extra=reduced_extra,
            )
        ]
        source_time_offset = cast(Any, self).source_time_offset
        reduced_source_time_offset = (
            source_time_offset[:1] if (source_time_offset == source_time_offset[0]).all() else 0.0
        )
        new_metadata = self._updated_metadata(op, {})
        result = self._create_new_instance(
            data=reduced_data,
            metadata=new_metadata,
            channel_metadata=new_channel_metadata,
            source_time_offset=reduced_source_time_offset,
            lineage=cast(Any, self)._required_semantic_lineage(),
        )
        return result

    @recipe_operation("wandas.audio.sum")
    def sum(self: T_Processing) -> T_Processing:
        """Sum all channels.

        Returns:
            A new ChannelFrame with summed signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))

    @recipe_operation("wandas.audio.mean")
    def mean(self: T_Processing) -> T_Processing:
        """Average all channels.

        Returns:
            A new ChannelFrame with averaged signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))

    @recipe_operation("wandas.audio.trim", version=1)
    def _trim_recipe_v1(
        self: T_Processing,
        start: float = 0,
        end: float | None = None,
    ) -> T_Processing:
        """Replay the released array-operation contract for saved Recipe plans."""
        if end is None:
            end = self.duration
        if start > end:
            raise ValueError("start must be less than end")
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", DeprecationWarning)
            operation = create_operation("trim", self.sampling_rate, start=start, end=end)
        start_sample = int(start * self.sampling_rate)
        return cast(
            T_Processing,
            cast(Any, self)._apply_operation_instance(
                operation,
                operation_name="trim",
                frame_metadata_updates={
                    "source_time_offset": cast(Any, self).source_time_offset + start_sample / self.sampling_rate,
                },
            ),
        )

    @recipe_operation("wandas.frame.time_slice")
    def trim(
        self: T_Processing,
        start: float = 0,
        end: float | None = None,
    ) -> T_Processing:
        """Trim the signal to the specified time range.

        Args:
            start: Start time (seconds)
            end: End time (seconds)

        Returns:
            New ChannelFrame containing the trimmed signal. The operation is a
                lazy structural time slice: channel labels, calibration, metadata,
                and channel IDs are preserved, while source-time offsets advance
                to the first selected sample.

        Raises:
            ValueError: If either time is negative or end is earlier than start
        """
        if start < 0 or (end is not None and end < 0):
            raise ValueError("Trim times must be non-negative")
        if end is not None and start > end:
            raise ValueError("start must be less than or equal to end")
        start_sample = int(start * self.sampling_rate)
        end_sample = self.n_samples if end is None else int(end * self.sampling_rate)
        result = cast(Any, self)[slice(None), slice(start_sample, end_sample)]
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.fix_length")
    def fix_length(
        self: T_Processing,
        length: int | None = None,
        duration: float | None = None,
    ) -> T_Processing:
        """Adjust the signal to the specified length.

        Args:
            duration: Signal length in seconds
            length: Signal length in samples

        Returns:
            New ChannelFrame containing the adjusted signal
        """

        result = self._apply_named_operation("fix_length", length=length, duration=duration)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.rms_trend", version=2)
    def rms_trend(
        self: T_Processing,
        frame_length: int = 2048,
        hop_length: int = 512,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Compute a linear RMS trend or an RMS amplitude level.

        This method calculates root mean square over centered, zero-padded
        sliding windows. Calibration is applied per channel. With ``dB=False``
        the output is linear and retains each channel's physical unit. With
        ``dB=True`` the output is ``20 * log10(max(window_rms / channel_ref,
        1e-12))``, bounded below by -240 dB. It is dB SPL only when the signal
        is pressure in Pa and the reference is ``2e-5 Pa``.

        Args:
            frame_length: Size of the sliding window in samples. Default is 2048.
            hop_length: Hop length between windows in samples. Default is 512.
            dB: Return amplitude level relative to each channel reference.
                Default is False.
            Aw: Apply the implemented A-frequency-weighting filter before RMS.
                Default is False.

        Returns:
            New lazy ChannelFrame with shape ``(n_channels, n_frames)``.
                Linear output retains the physical channel unit; dB output encodes
                its original reference in the channel unit (for example,
                ``dB SPL re 2e-05 Pa``). Its sampling rate is divided by
                ``hop_length``. The input Frame remains unchanged and the result
                carries the new operation in lineage.

        Raises:
            ValueError: If the window parameters or channel references are
                invalid for the input Frame.
        """
        # Access _channel_metadata to retrieve reference values
        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values()

        params = {
            "frame_length": frame_length,
            "hop_length": hop_length,
            "dB": dB,
            "Aw": Aw,
            **({"ref": ref_values} if ref_values else {}),
        }
        if dB:
            result = cast(Any, self)._apply_level_operation("rms_trend", **params)
        else:
            result = self._apply_named_operation("rms_trend", **params)

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.rms_trend", version=1)
    def _rms_trend_recipe_v1(
        self: T_Processing,
        frame_length: int = 2048,
        hop_length: int = 512,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Replay the released Recipe v1 numerical and metadata contract."""
        from wandas.processing.temporal import _RecipeRmsTrendV1

        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values()
        operation = _RecipeRmsTrendV1(
            self.sampling_rate,
            frame_length=frame_length,
            hop_length=hop_length,
            ref=ref_values,
            dB=dB,
            Aw=Aw,
        )
        result = cast(Any, self)._apply_operation_instance(
            operation,
            operation_name="rms_trend",
        )
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.sound_level", version=2)
    def sound_level(
        self: T_Processing,
        freq_weighting: str | None = "Z",
        time_weighting: str = "Fast",
        dB: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Compute a frequency- and time-weighted RMS or reference-relative level.

        The selected frequency weighting is applied first. Squared samples are
        then smoothed by a first-order exponential filter using 125 ms (Fast)
        or 1 s (Slow). With ``dB=False`` the square root is returned in the
        calibrated input unit. With ``dB=True`` the result is
        ``10 * log10(max(smoothed_power / channel_ref**2, 1e-20))``, bounded
        below by -200 dB. A Pa channel whose reference is ``2e-5 Pa`` yields
        dB SPL; an uncalibrated channel yields relative dB re 1 input unit.

        This method validates the implemented filters and time constants, not
        the complete tolerance, detector, calibration, or directional-response
        requirements of an IEC/JIS sound-level meter.

        Args:
            freq_weighting: Implemented frequency-weighting curve: ``"A"``,
                ``"C"``, or flat ``"Z"``. ``None`` is treated as ``"Z"``.
            time_weighting: Exponential time constant: ``"Fast"`` (125 ms) or
                ``"Slow"`` (1 s).
            dB: Return level relative to the channel reference when ``True``;
                otherwise return linear time-weighted RMS.

        Returns:
            New lazy ChannelFrame with shape ``(n_channels, n_samples)`` and
                the input sampling rate. Linear output retains the physical channel
                unit; dB output encodes its original reference in the channel unit
                (for example, ``dB SPL re 2e-05 Pa``). The input Frame remains
                unchanged and the result preserves metadata while extending
                lineage.

        Raises:
            ValueError: If the frequency/time weighting or channel references
                are invalid for the input Frame.
        """
        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values(
            require_non_default=True,
        )

        params = {
            "freq_weighting": freq_weighting,
            "time_weighting": time_weighting,
            "dB": dB,
            **({"ref": ref_values} if ref_values else {}),
        }
        if dB:
            result = cast(Any, self)._apply_level_operation("sound_level", **params)
        else:
            result = self._apply_named_operation("sound_level", **params)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.sound_level", version=1)
    def _sound_level_recipe_v1(
        self: T_Processing,
        freq_weighting: str | None = "Z",
        time_weighting: str = "Fast",
        dB: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Replay the released Recipe v1 numerical and metadata contract."""
        from wandas.processing.temporal import _RecipeSoundLevelV1

        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values(
            require_non_default=True,
        )
        operation = _RecipeSoundLevelV1(
            self.sampling_rate,
            freq_weighting=freq_weighting,
            time_weighting=time_weighting,
            dB=dB,
            **({"ref": ref_values} if ref_values else {}),
        )
        result = cast(Any, self)._apply_operation_instance(
            operation,
            operation_name="sound_level",
        )
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.channel_difference")
    def channel_difference(self: T_Processing, other_channel: int | str = 0) -> T_Processing:
        """Compute index-wise differences between channels.

        ``channel_difference`` subtracts the selected reference channel from
        each channel at the current array indices. It does not compare
        per-channel ``source_time_offset`` values and does not perform
        source-time alignment.

        Args:
            other_channel: Index or label of the reference channel. Default is 0.

        Returns:
            New ChannelFrame containing the channel difference with the input
                source-time offsets preserved.
        """
        # label2index is a method of BaseFrame
        if isinstance(other_channel, str) and hasattr(self, "label2index"):
            other_channel = self.label2index(other_channel)

        result = self._apply_named_operation("channel_difference", other_channel=other_channel)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.resampling")
    def resampling(
        self: T_Processing,
        target_sr: float,
        **kwargs: Any,
    ) -> T_Processing:
        """Resample audio data.

        Args:
            target_sr: Target sampling rate (Hz)
            **kwargs: Additional resampling parameters

        Returns:
            Resampled ChannelFrame
        """
        return cast(
            T_Processing,
            self._apply_named_operation(
                "resampling",
                target_sr=target_sr,
                **kwargs,
            ),
        )

    def _hpss(
        self: T_Processing,
        operation_name: str,
        kernel_size: HpssKernelSize = 31,
        power: HpssFloatLike = 2,
        margin: HpssMargin = 1,
        n_fft: HpssIntLike = 2048,
        hop_length: HpssIntLike | None = None,
        win_length: HpssIntLike | None = None,
        window: "Any" = "hann",
        center: bool = True,
        pad_mode: "str" = "constant",
    ) -> T_Processing:
        """Shared implementation for HPSS harmonic/percussive extraction."""
        result = self._apply_named_operation(
            operation_name,
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        )
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.hpss_harmonic")
    def hpss_harmonic(
        self: T_Processing,
        kernel_size: HpssKernelSize = 31,
        power: HpssFloatLike = 2,
        margin: HpssMargin = 1,
        n_fft: HpssIntLike = 2048,
        hop_length: HpssIntLike | None = None,
        win_length: HpssIntLike | None = None,
        window: "Any" = "hann",
        center: bool = True,
        pad_mode: "str" = "constant",
    ) -> T_Processing:
        """
        Extract harmonic components using HPSS
         (Harmonic-Percussive Source Separation).

        This method separates the harmonic (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.
            n_fft: Size of FFT window.
            hop_length: Hop length for STFT.
            win_length: Window length for STFT.
            window: Window type for STFT.
            center: If True, center the frames.
            pad_mode: Padding mode for STFT.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        return cast(
            T_Processing,
            self._hpss(
                "hpss_harmonic",
                kernel_size=kernel_size,
                power=power,
                margin=margin,
                n_fft=n_fft,
                hop_length=hop_length,
                win_length=win_length,
                window=window,
                center=center,
                pad_mode=pad_mode,
            ),
        )

    @recipe_operation("wandas.audio.hpss_percussive")
    def hpss_percussive(
        self: T_Processing,
        kernel_size: HpssKernelSize = 31,
        power: HpssFloatLike = 2,
        margin: HpssMargin = 1,
        n_fft: HpssIntLike = 2048,
        hop_length: HpssIntLike | None = None,
        win_length: HpssIntLike | None = None,
        window: "Any" = "hann",
        center: bool = True,
        pad_mode: "str" = "constant",
    ) -> T_Processing:
        """
        Extract percussive components using HPSS
        (Harmonic-Percussive Source Separation).

        This method separates the percussive (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        return cast(
            T_Processing,
            self._hpss(
                "hpss_percussive",
                kernel_size=kernel_size,
                power=power,
                margin=margin,
                n_fft=n_fft,
                hop_length=hop_length,
                win_length=win_length,
                window=window,
                center=center,
                pad_mode=pad_mode,
            ),
        )

    @recipe_operation("wandas.audio.loudness_zwtv")
    def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
        """
        Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of non-stationary signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            New ChannelFrame containing time-varying loudness values in sones.
                Each channel is processed independently.
                The output sampling rate is adjusted based on the loudness
                calculation time resolution (typically ~500 Hz for 2ms steps).

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate loudness for a signal:
            >>> import wandas as wd
            >>> signal = wd.read("audio.wav")
            >>> loudness = signal.loudness_zwtv(field_type="free")
            >>> loudness.plot(title="Time-varying Loudness")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwtv(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

        Notes:
            - The output contains time-varying loudness values in sones
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - The time resolution is approximately 2ms (determined by the algorithm)
            - For multi-channel signals, loudness is calculated per channel
            - The output sampling rate is updated to reflect the time resolution

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 2ms analysis step. This differs slightly from the MoSQITo
            library, which uses the center time of each step. For example:

            - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
            - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

            The difference is very small (~1ms) and does not affect the loudness
            values themselves. This design choice ensures consistency with
            wandas's time axis convention across all frame types.

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        result = self._apply_named_operation("loudness_zwtv", field_type=field_type)

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    def loudness_zwst(self: ProcessingFrameProtocol, field_type: str = "free") -> "NDArrayReal":
        """
        Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of stationary (steady) signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        This method is suitable for analyzing steady sounds such as fan noise,
        constant machinery sounds, or other stationary signals.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            Loudness values in sones, one per channel. Shape: (n_channels,)

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate steady-state loudness for a fan noise:
            >>> import wandas as wd
            >>> signal = wd.read("fan_noise.wav")
            >>> loudness = signal.loudness_zwst(field_type="free")
            >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
            >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwst(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
            >>> print(f"Free field: {loudness_free[0]:.2f} sones")
            >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

        Notes:
            - Returns a 1D array with one loudness value per channel
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - For multi-channel signals, loudness is calculated independently
              per channel
            - This method is designed for stationary signals (constant sounds)
            - For time-varying signals, use loudness_zwtv() instead
            - Similar to the rms property, returns NDArrayReal for consistency

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        from wandas.processing.psychoacoustic import LoudnessZwst

        operation = LoudnessZwst(self.sampling_rate, field_type=field_type)
        return self._compute_scalar_metric(operation)

    @recipe_operation("wandas.audio.roughness_dw")
    def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
        """Calculate time-varying roughness using Daniel and Weber method.

        Roughness is a psychoacoustic metric that quantifies the perceived
        harshness or roughness of a sound, measured in asper. This method
        implements the Daniel & Weber (1997) standard calculation.

        The calculation follows the standard formula:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            New ChannelFrame containing time-varying roughness values in asper.
                The output sampling rate depends on the overlap parameter.

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Calculate roughness for a motor noise:
            >>> import wandas as wd
            >>> signal = wd.read("motor_noise.wav")
            >>> roughness = signal.roughness_dw(overlap=0.5)
            >>> roughness.plot(ylabel="Roughness [asper]")

            Analyze roughness statistics:
            >>> mean_roughness = roughness.data.mean()
            >>> max_roughness = roughness.data.max()
            >>> print(f"Mean: {mean_roughness:.2f} asper")
            >>> print(f"Max: {max_roughness:.2f} asper")

            Compare before and after modification:
            >>> before = wd.read("motor_before.wav").roughness_dw()
            >>> after = wd.read("motor_after.wav").roughness_dw()
            >>> improvement = before.data.mean() - after.data.mean()
            >>> print(f"Roughness reduction: {improvement:.2f} asper")

        Notes:
            - Returns a ChannelFrame with time-varying roughness values
            - Typical roughness values: 0-2 asper for most sounds
            - Higher values indicate rougher, harsher sounds
            - For multi-channel signals, roughness is calculated independently
              per channel
            - This is the standard-compliant total roughness (R)
            - For detailed Bark-band analysis, use roughness_dw_spec() instead

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 200ms analysis window. This differs from the MoSQITo library,
            which uses the center time of each window. For example:

            - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
            - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

            The difference is constant (half the window duration = 100ms) and
            does not affect the roughness values themselves. This design choice
            ensures consistency with wandas's time axis convention across all
            frame types.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """
        logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
        result = self._apply_named_operation("roughness_dw", overlap=overlap)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.roughness_dw_spec")
    def roughness_dw_spec(self: ProcessingFrameProtocol, overlap: float = 0.5) -> "RoughnessFrame":
        """Calculate specific roughness with Bark-band frequency information.

        This method returns detailed roughness analysis data organized by
        Bark frequency bands over time, allowing for frequency-specific
        roughness analysis. It uses the Daniel & Weber (1997) method.

        The relationship between total roughness and specific roughness:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            RoughnessFrame containing:
                - data: Specific roughness by Bark band, shape (47, n_time)
                        for mono or (n_channels, 47, n_time) for multi-channel
                - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
                - time: Time axis for each analysis frame
                - overlap: Overlap coefficient used
                - plot(): Method for Bark-Time heatmap visualization

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Analyze frequency-specific roughness:
            >>> import wandas as wd
            >>> import numpy as np
            >>> signal = wd.read("motor.wav")
            >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
            >>>
            >>> # Plot Bark-Time heatmap
            >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
            >>>
            >>> # Find dominant Bark band
            >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
            >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
            >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
            >>>
            >>> # Extract specific Bark band time series
            >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
            >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
            >>>
            >>> # Verify standard formula
            >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
            >>> # This should match signal.roughness_dw(overlap=0.5).data

        Notes:
            - Returns a RoughnessFrame (not ChannelFrame)
            - Contains 47 Bark bands from 0.5 to 23.5 Bark
            - Each Bark band corresponds to a critical band of hearing
            - Useful for identifying which frequencies contribute most to roughness
            - The specific roughness can be integrated to obtain total roughness
            - For simple time-series analysis, use roughness_dw() instead

            **Time axis convention:**
            The time axis represents the start time of each 200ms analysis
            window, consistent with roughness_dw() and other wandas methods.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """

        params = {"overlap": overlap}
        operation_name = "roughness_dw_spec"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance via factory
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing lazily to the effective Dask data.
        r_spec_dask = operation.process(self._effective_data)

        # Get metadata updates (sampling rate, bark_axis)
        metadata_updates = operation.get_metadata_updates()

        # Build metadata
        new_metadata = {**self.metadata, **params}

        # Extract bark_axis with proper type handling
        bark_axis_value = metadata_updates.get("bark_axis")
        if bark_axis_value is None:
            raise ValueError("Operation did not provide bark_axis in metadata")

        # Create RoughnessFrame. operation.get_metadata_updates() should provide
        # sampling_rate and bark_axis
        lineage = cast(Any, self)._required_semantic_lineage()
        roughness_frame = RoughnessFrame(
            data=r_spec_dask,
            sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
            bark_axis=bark_axis_value,
            overlap=overlap,
            label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
            metadata=new_metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=lineage,
            previous=cast("BaseFrame[NDArrayReal]", self),
        )

        logger.debug(
            "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
            operation_name,
            r_spec_dask.shape,
            roughness_frame.sampling_rate,
        )

        return roughness_frame

    @recipe_operation("wandas.audio.fade")
    def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
        """Apply symmetric fade-in and fade-out to the signal using Tukey window.

        This method applies a symmetric fade-in and fade-out envelope to the signal
        using a Tukey (tapered cosine) window. The fade duration is the same for
        both the beginning and end of the signal.

        Args:
            fade_ms: Fade duration in milliseconds for each end of the signal.
                The total fade duration is 2 * fade_ms. Default is 50 ms.
                Must be positive and less than half the signal duration.

        Returns:
            New ChannelFrame containing the faded signal

        Raises:
            ValueError: If fade_ms is negative or too long for the signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("audio.wav")
            >>> # Apply 10ms fade-in and fade-out
            >>> faded = signal.fade(fade_ms=10.0)
            >>> # Apply very short fade (almost no effect)
            >>> faded_short = signal.fade(fade_ms=0.1)

        Notes:
            - Uses SciPy's Tukey window for smooth fade transitions
            - Fade is applied symmetrically to both ends of the signal
            - The Tukey window alpha parameter is computed automatically
              based on the fade duration and signal length
            - For multi-channel signals, the same fade envelope is applied
              to all channels
            - Lazy evaluation is preserved - computation occurs only when needed
        """
        logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
        result = self._apply_named_operation("fade", fade_ms=fade_ms)
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.sharpness_din")
    def sharpness_din(
        self: T_Processing,
        weighting: str = "din",
        field_type: str = "free",
    ) -> T_Processing:
        """Calculate sharpness using DIN 45692 method.

        This method computes the time-varying sharpness of the signal
        according to DIN 45692 standard, which quantifies the perceived
        sharpness of sounds.

        Args:
            weighting: str, default="din". Weighting type for sharpness calculation. Options:
                - 'din': DIN 45692 method
                - 'aures': Aures method
                - 'bismarck': Bismarck method
                - 'fastl': Fastl method
            field_type: str, default="free". Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)

        Returns:
            T_Processing: New ChannelFrame containing sharpness time series in acum.
                The output sampling rate is approximately 500 Hz (2ms time steps).

        Raises:
            ValueError: If the signal sampling rate is not supported by the algorithm.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("sharp_sound.wav")
            >>> sharpness = signal.sharpness_din(weighting="din", field_type="free")
            >>> print(f"Mean sharpness: {sharpness.data.mean():.2f} acum")

        Notes:
            - Sharpness is measured in acum (acum = 1 when the sound has the
          same sharpness as a 2 kHz narrow-band noise at 60 dB SPL)
            - The calculation uses MoSQITo's implementation of DIN 45692
            - Output sampling rate is fixed at 500 Hz regardless of input rate
            - For multi-channel signals, sharpness is calculated per channel

        References:
            .. [1] DIN 45692:2009, "Measurement technique for the simulation of the
               auditory sensation of sharpness"
        """
        logger.debug(
            "Setting up sharpness DIN calculation with weighting=%s, field_type=%s (lazy)",
            weighting,
            field_type,
        )
        result = self._apply_named_operation(
            "sharpness_din",
            weighting=weighting,
            field_type=field_type,
        )
        return cast(T_Processing, result)

    def sharpness_din_st(
        self: ProcessingFrameProtocol,
        weighting: str = "din",
        field_type: str = "free",
    ) -> "NDArrayReal":
        """Calculate steady-state sharpness using DIN 45692 method.

        This method computes the steady-state sharpness of the signal
        according to DIN 45692 standard, which quantifies the perceived
        sharpness of stationary sounds.

        Args:
            weighting: str, default="din". Weighting type for sharpness calculation. Options:
                - 'din': DIN 45692 method
                - 'aures': Aures method
                - 'bismarck': Bismarck method
                - 'fastl': Fastl method
            field_type: str, default="free". Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)

        Returns:
            NDArrayReal: Sharpness values in acum, one per channel. Shape: (n_channels,)

        Raises:
            ValueError: If the signal sampling rate is not supported by the algorithm.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read("constant_tone.wav")
            >>> sharpness = signal.sharpness_din_st(weighting="din", field_type="free")
            >>> print(f"Steady-state sharpness: {sharpness[0]:.2f} acum")

        Notes:
            - Sharpness is measured in acum (acum = 1 when the sound has the
          same sharpness as a 2 kHz narrow-band noise at 60 dB SPL)
            - The calculation uses MoSQITo's implementation of DIN 45692
            - Output is a single value per channel, suitable for stationary signals
            - For multi-channel signals, sharpness is calculated per channel

        References:
            .. [1] DIN 45692:2009, "Measurement technique for the simulation of the
               auditory sensation of sharpness"
        """
        from wandas.processing.psychoacoustic import SharpnessDinSt

        operation = SharpnessDinSt(self.sampling_rate, weighting=weighting, field_type=field_type)
        return self._compute_scalar_metric(operation)

Functions

apply(func, output_shape_func=None, output_frame_class=None, output_frame_kwargs=None, *, dask_pure=True, **kwargs)

apply(func: Callable[..., Any], output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = ..., output_frame_class: None = ..., output_frame_kwargs: dict[str, Any] | None = ..., *, dask_pure: bool = ..., **kwargs: Any) -> T_Processing
apply(func: Callable[..., Any], output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = ..., output_frame_class: type[T_OutputFrame] = ..., output_frame_kwargs: dict[str, Any] | None = ..., *, dask_pure: bool = ..., **kwargs: Any) -> T_OutputFrame

Apply a custom function to the signal.

Parameters:

Name Type Description Default
func Callable[..., Any]

Function to apply.

required
output_shape_func Callable[[tuple[int, ...]], tuple[int, ...]] | None

Optional function to calculate output shape.

None
output_frame_class type[T_OutputFrame] | None

Optional frame class for the output. When provided, the result is wrapped in this class instead of the caller's type, enabling domain transitions (e.g. ChannelFrame -> SpectralFrame).

None
output_frame_kwargs dict[str, Any] | None

Extra constructor keyword arguments required by output_frame_class (e.g. {"n_fft": 1024}).

None
dask_pure bool

Dask execution-control flag for delayed custom operations. Set to False for non-deterministic or side-effecting functions. This value is not forwarded to func or recorded in operation history.

True
**kwargs Any

Additional arguments for the function.

{}

Returns:

Type Description
Any

New frame with the custom function applied.

Source code in wandas/frames/mixins/channel_processing_mixin.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
@_runtime_apply_semantic
def apply(
    self: T_Processing,
    func: Callable[..., Any],
    output_shape_func: Callable[[tuple[int, ...]], tuple[int, ...]] | None = None,
    output_frame_class: type[T_OutputFrame] | None = None,
    output_frame_kwargs: dict[str, Any] | None = None,
    *,
    dask_pure: bool = True,
    **kwargs: Any,
) -> Any:
    """Apply a custom function to the signal.

    Args:
        func: Function to apply.
        output_shape_func: Optional function to calculate output shape.
        output_frame_class: Optional frame class for the output.  When
            provided, the result is wrapped in this class instead of the
            caller's type, enabling domain transitions (e.g.
            ``ChannelFrame`` -> ``SpectralFrame``).
        output_frame_kwargs: Extra constructor keyword arguments required
            by *output_frame_class* (e.g. ``{"n_fft": 1024}``).
        dask_pure: Dask execution-control flag for delayed custom
            operations. Set to ``False`` for non-deterministic or
            side-effecting functions. This value is not forwarded to
            *func* or recorded in operation history.
        **kwargs: Additional arguments for the function.

    Returns:
        New frame with the custom function applied.
    """
    from wandas.processing.custom import CustomOperation

    # Pre-validation: check for parameter name conflicts
    if "sampling_rate" in kwargs:
        raise ValueError(
            "Parameter name conflict\n"
            "  Cannot use 'sampling_rate' as a parameter in apply().\n"
            "  The sampling rate is automatically provided from the frame.\n"
            "  Suggested alternatives: 'sr', 'sample_rate', or 'fs'\n"
            f"  Received params: {list(kwargs.keys())}"
        )
    if "pure" in kwargs:
        raise ValueError(
            "Parameter name conflict\n"
            "  Cannot use 'pure' as a parameter in apply().\n"
            "  'pure' is reserved for operation purity and Dask task semantics.\n"
            "  Suggested alternative: rename the function argument to 'is_pure'.\n"
            f"  Received params: {list(kwargs.keys())}"
        )

    operation = CustomOperation(
        sampling_rate=self.sampling_rate,
        func=func,
        output_shape_func=output_shape_func,
        dask_pure=dask_pure,
        **kwargs,
    )

    return cast(Any, self)._apply_operation_instance(
        operation,
        output_frame_class=output_frame_class,
        output_frame_kwargs=output_frame_kwargs,
    )

high_pass_filter(cutoff, order=4)

Apply a high-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
@recipe_operation("wandas.audio.highpass_filter")
def high_pass_filter(self: T_Processing, cutoff: float, order: int = 4) -> T_Processing:
    """Apply a high-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)")
    result = self._apply_named_operation("highpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)

low_pass_filter(cutoff, order=4)

Apply a low-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
@recipe_operation("wandas.audio.lowpass_filter")
def low_pass_filter(self: T_Processing, cutoff: float, order: int = 4) -> T_Processing:
    """Apply a low-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)")
    result = self._apply_named_operation("lowpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)

band_pass_filter(low_cutoff, high_cutoff, order=4)

Apply a band-pass filter to the signal.

Parameters:

Name Type Description Default
low_cutoff float

Lower cutoff frequency (Hz)

required
high_cutoff float

Higher cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
@recipe_operation("wandas.audio.bandpass_filter")
def band_pass_filter(self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4) -> T_Processing:
    """Apply a band-pass filter to the signal.

    Args:
        low_cutoff: Lower cutoff frequency (Hz)
        high_cutoff: Higher cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up bandpass filter: low_cutoff={low_cutoff}, high_cutoff={high_cutoff}, order={order} (lazy)"
    )
    result = self._apply_named_operation(
        "bandpass_filter",
        low_cutoff=low_cutoff,
        high_cutoff=high_cutoff,
        order=order,
    )
    return cast(T_Processing, result)

normalize(norm=float('inf'), axis=-1, threshold=None, fill=None)

Normalize signal levels using NumPy-based normalization.

This method normalizes the signal amplitude according to the specified norm.

Parameters:

Name Type Description Default
norm float | None

Norm type. Default is np.inf (maximum absolute value normalization). Supported values: - np.inf: Maximum absolute value normalization - -np.inf: Minimum absolute value normalization - 0: Peak normalization - float: Lp norm - None: No normalization

float('inf')
axis int | None

Axis along which to normalize. Default is -1 (time axis). - -1: Normalize along time axis (each channel independently) - None: Global normalization across all axes - int: Normalize along specified axis

-1
threshold float | None

Threshold below which values are considered zero. If None, no threshold is applied.

None
fill bool | None

Value to fill when the norm is zero. If None, the zero vector remains zero.

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the normalized signal

Examples:

>>> import wandas as wd
>>> signal = wd.read("audio.wav")
>>> # Normalize to maximum absolute value of 1.0 (per channel)
>>> normalized = signal.normalize()
>>> # Global normalization across all channels
>>> normalized_global = signal.normalize(axis=None)
>>> # L2 normalization
>>> normalized_l2 = signal.normalize(norm=2)
Source code in wandas/frames/mixins/channel_processing_mixin.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
@recipe_operation("wandas.audio.normalize")
def normalize(
    self: T_Processing,
    norm: float | None = float("inf"),
    axis: int | None = -1,
    threshold: float | None = None,
    fill: bool | None = None,
) -> T_Processing:
    """Normalize signal levels using NumPy-based normalization.

    This method normalizes the signal amplitude according to the specified norm.

    Args:
        norm: Norm type. Default is np.inf (maximum absolute value normalization).
            Supported values:
            - np.inf: Maximum absolute value normalization
            - -np.inf: Minimum absolute value normalization
            - 0: Peak normalization
            - float: Lp norm
            - None: No normalization
        axis: Axis along which to normalize. Default is -1 (time axis).
            - -1: Normalize along time axis (each channel independently)
            - None: Global normalization across all axes
            - int: Normalize along specified axis
        threshold: Threshold below which values are considered zero.
            If None, no threshold is applied.
        fill: Value to fill when the norm is zero.
            If None, the zero vector remains zero.

    Returns:
        New ChannelFrame containing the normalized signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read("audio.wav")
        >>> # Normalize to maximum absolute value of 1.0 (per channel)
        >>> normalized = signal.normalize()
        >>> # Global normalization across all channels
        >>> normalized_global = signal.normalize(axis=None)
        >>> # L2 normalization
        >>> normalized_l2 = signal.normalize(norm=2)
    """
    logger.debug(f"Setting up normalize: norm={norm}, axis={axis}, threshold={threshold}, fill={fill} (lazy)")
    result = self._apply_named_operation("normalize", norm=norm, axis=axis, threshold=threshold, fill=fill)
    return cast(T_Processing, result)

remove_dc()

Remove DC component (DC offset) from the signal.

This method removes the DC (direct current) component by subtracting the mean value from each channel. This is equivalent to centering the signal around zero.

Returns:

Type Description
T_Processing

New ChannelFrame with DC component removed

Examples:

>>> import wandas as wd
>>> import numpy as np
>>> # Create signal with DC offset
>>> signal = wd.read("audio.wav")
>>> signal_with_dc = signal + 2.0  # Add DC offset
>>> # Remove DC offset
>>> signal_clean = signal_with_dc.remove_dc()
>>> # Verify DC removal
>>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)
Notes
  • This operation is performed per channel
  • Equivalent to applying a high-pass filter with very low cutoff
  • Useful for removing sensor drift or measurement offset
Source code in wandas/frames/mixins/channel_processing_mixin.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
@recipe_operation("wandas.audio.remove_dc")
def remove_dc(self: T_Processing) -> T_Processing:
    """Remove DC component (DC offset) from the signal.

    This method removes the DC (direct current) component by subtracting
    the mean value from each channel. This is equivalent to centering the
    signal around zero.

    Returns:
        New ChannelFrame with DC component removed

    Examples:
        >>> import wandas as wd
        >>> import numpy as np
        >>> # Create signal with DC offset
        >>> signal = wd.read("audio.wav")
        >>> signal_with_dc = signal + 2.0  # Add DC offset
        >>> # Remove DC offset
        >>> signal_clean = signal_with_dc.remove_dc()
        >>> # Verify DC removal
        >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

    Notes:
        - This operation is performed per channel
        - Equivalent to applying a high-pass filter with very low cutoff
        - Useful for removing sensor drift or measurement offset
    """
    logger.debug("Setting up DC removal (lazy)")
    result = self._apply_named_operation("remove_dc")
    return cast(T_Processing, result)

a_weighting()

Apply A-weighting filter to the signal.

A-weighting adjusts the frequency response to approximate human auditory perception using the implemented digital curve. This returns a weighted linear waveform in the input unit; it does not calculate RMS or convert to dB. No sound-level-meter conformance is implied.

Returns:

Type Description
T_Processing

New ChannelFrame containing the A-weighted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
@recipe_operation("wandas.audio.a_weighting")
def a_weighting(self: T_Processing) -> T_Processing:
    """Apply A-weighting filter to the signal.

    A-weighting adjusts the frequency response to approximate human
    auditory perception using the implemented digital curve. This returns
    a weighted linear waveform in the input unit; it does not calculate
    RMS or convert to dB. No sound-level-meter conformance is implied.

    Returns:
        New ChannelFrame containing the A-weighted signal
    """
    result = self._apply_named_operation("a_weighting")
    return cast(T_Processing, result)

abs()

Compute the absolute value of the signal.

Returns:

Type Description
T_Processing

New ChannelFrame containing the absolute values

Source code in wandas/frames/mixins/channel_processing_mixin.py
381
382
383
384
385
386
387
388
389
@recipe_operation("wandas.audio.abs")
def abs(self: T_Processing) -> T_Processing:
    """Compute the absolute value of the signal.

    Returns:
        New ChannelFrame containing the absolute values
    """
    result = self._apply_named_operation("abs")
    return cast(T_Processing, result)

power(exponent=2.0)

Compute the power of the signal.

Parameters:

Name Type Description Default
exponent float

Exponent to raise the signal to. Default is 2.0.

2.0

Returns:

Type Description
T_Processing

New ChannelFrame containing the powered signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
391
392
393
394
395
396
397
398
399
400
401
402
@recipe_operation("wandas.audio.power")
def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
    """Compute the power of the signal.

    Args:
        exponent: Exponent to raise the signal to. Default is 2.0.

    Returns:
        New ChannelFrame containing the powered signal
    """
    result = self._apply_named_operation("power", exponent=exponent)
    return cast(T_Processing, result)

sum()

Sum all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with summed signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
442
443
444
445
446
447
448
449
@recipe_operation("wandas.audio.sum")
def sum(self: T_Processing) -> T_Processing:
    """Sum all channels.

    Returns:
        A new ChannelFrame with summed signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))

mean()

Average all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with averaged signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
451
452
453
454
455
456
457
458
@recipe_operation("wandas.audio.mean")
def mean(self: T_Processing) -> T_Processing:
    """Average all channels.

    Returns:
        A new ChannelFrame with averaged signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))

trim(start=0, end=None)

Trim the signal to the specified time range.

Parameters:

Name Type Description Default
start float

Start time (seconds)

0
end float | None

End time (seconds)

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the trimmed signal. The operation is a lazy structural time slice: channel labels, calibration, metadata, and channel IDs are preserved, while source-time offsets advance to the first selected sample.

Raises:

Type Description
ValueError

If either time is negative or end is earlier than start

Source code in wandas/frames/mixins/channel_processing_mixin.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
@recipe_operation("wandas.frame.time_slice")
def trim(
    self: T_Processing,
    start: float = 0,
    end: float | None = None,
) -> T_Processing:
    """Trim the signal to the specified time range.

    Args:
        start: Start time (seconds)
        end: End time (seconds)

    Returns:
        New ChannelFrame containing the trimmed signal. The operation is a
            lazy structural time slice: channel labels, calibration, metadata,
            and channel IDs are preserved, while source-time offsets advance
            to the first selected sample.

    Raises:
        ValueError: If either time is negative or end is earlier than start
    """
    if start < 0 or (end is not None and end < 0):
        raise ValueError("Trim times must be non-negative")
    if end is not None and start > end:
        raise ValueError("start must be less than or equal to end")
    start_sample = int(start * self.sampling_rate)
    end_sample = self.n_samples if end is None else int(end * self.sampling_rate)
    result = cast(Any, self)[slice(None), slice(start_sample, end_sample)]
    return cast(T_Processing, result)

fix_length(length=None, duration=None)

Adjust the signal to the specified length.

Parameters:

Name Type Description Default
duration float | None

Signal length in seconds

None
length int | None

Signal length in samples

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the adjusted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
@recipe_operation("wandas.audio.fix_length")
def fix_length(
    self: T_Processing,
    length: int | None = None,
    duration: float | None = None,
) -> T_Processing:
    """Adjust the signal to the specified length.

    Args:
        duration: Signal length in seconds
        length: Signal length in samples

    Returns:
        New ChannelFrame containing the adjusted signal
    """

    result = self._apply_named_operation("fix_length", length=length, duration=duration)
    return cast(T_Processing, result)

rms_trend(frame_length=2048, hop_length=512, dB=False, Aw=False)

Compute a linear RMS trend or an RMS amplitude level.

This method calculates root mean square over centered, zero-padded sliding windows. Calibration is applied per channel. With dB=False the output is linear and retains each channel's physical unit. With dB=True the output is 20 * log10(max(window_rms / channel_ref, 1e-12)), bounded below by -240 dB. It is dB SPL only when the signal is pressure in Pa and the reference is 2e-5 Pa.

Parameters:

Name Type Description Default
frame_length int

Size of the sliding window in samples. Default is 2048.

2048
hop_length int

Hop length between windows in samples. Default is 512.

512
dB bool

Return amplitude level relative to each channel reference. Default is False.

False
Aw bool

Apply the implemented A-frequency-weighting filter before RMS. Default is False.

False

Returns:

Type Description
T_Processing

New lazy ChannelFrame with shape (n_channels, n_frames). Linear output retains the physical channel unit; dB output encodes its original reference in the channel unit (for example, dB SPL re 2e-05 Pa). Its sampling rate is divided by hop_length. The input Frame remains unchanged and the result carries the new operation in lineage.

Raises:

Type Description
ValueError

If the window parameters or channel references are invalid for the input Frame.

Source code in wandas/frames/mixins/channel_processing_mixin.py
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
@recipe_operation("wandas.audio.rms_trend", version=2)
def rms_trend(
    self: T_Processing,
    frame_length: int = 2048,
    hop_length: int = 512,
    dB: bool = False,  # noqa: N803
    Aw: bool = False,  # noqa: N803
) -> T_Processing:
    """Compute a linear RMS trend or an RMS amplitude level.

    This method calculates root mean square over centered, zero-padded
    sliding windows. Calibration is applied per channel. With ``dB=False``
    the output is linear and retains each channel's physical unit. With
    ``dB=True`` the output is ``20 * log10(max(window_rms / channel_ref,
    1e-12))``, bounded below by -240 dB. It is dB SPL only when the signal
    is pressure in Pa and the reference is ``2e-5 Pa``.

    Args:
        frame_length: Size of the sliding window in samples. Default is 2048.
        hop_length: Hop length between windows in samples. Default is 512.
        dB: Return amplitude level relative to each channel reference.
            Default is False.
        Aw: Apply the implemented A-frequency-weighting filter before RMS.
            Default is False.

    Returns:
        New lazy ChannelFrame with shape ``(n_channels, n_frames)``.
            Linear output retains the physical channel unit; dB output encodes
            its original reference in the channel unit (for example,
            ``dB SPL re 2e-05 Pa``). Its sampling rate is divided by
            ``hop_length``. The input Frame remains unchanged and the result
            carries the new operation in lineage.

    Raises:
        ValueError: If the window parameters or channel references are
            invalid for the input Frame.
    """
    # Access _channel_metadata to retrieve reference values
    ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values()

    params = {
        "frame_length": frame_length,
        "hop_length": hop_length,
        "dB": dB,
        "Aw": Aw,
        **({"ref": ref_values} if ref_values else {}),
    }
    if dB:
        result = cast(Any, self)._apply_level_operation("rms_trend", **params)
    else:
        result = self._apply_named_operation("rms_trend", **params)

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)

sound_level(freq_weighting='Z', time_weighting='Fast', dB=False)

Compute a frequency- and time-weighted RMS or reference-relative level.

The selected frequency weighting is applied first. Squared samples are then smoothed by a first-order exponential filter using 125 ms (Fast) or 1 s (Slow). With dB=False the square root is returned in the calibrated input unit. With dB=True the result is 10 * log10(max(smoothed_power / channel_ref**2, 1e-20)), bounded below by -200 dB. A Pa channel whose reference is 2e-5 Pa yields dB SPL; an uncalibrated channel yields relative dB re 1 input unit.

This method validates the implemented filters and time constants, not the complete tolerance, detector, calibration, or directional-response requirements of an IEC/JIS sound-level meter.

Parameters:

Name Type Description Default
freq_weighting str | None

Implemented frequency-weighting curve: "A", "C", or flat "Z". None is treated as "Z".

'Z'
time_weighting str

Exponential time constant: "Fast" (125 ms) or "Slow" (1 s).

'Fast'
dB bool

Return level relative to the channel reference when True; otherwise return linear time-weighted RMS.

False

Returns:

Type Description
T_Processing

New lazy ChannelFrame with shape (n_channels, n_samples) and the input sampling rate. Linear output retains the physical channel unit; dB output encodes its original reference in the channel unit (for example, dB SPL re 2e-05 Pa). The input Frame remains unchanged and the result preserves metadata while extending lineage.

Raises:

Type Description
ValueError

If the frequency/time weighting or channel references are invalid for the input Frame.

Source code in wandas/frames/mixins/channel_processing_mixin.py
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
@recipe_operation("wandas.audio.sound_level", version=2)
def sound_level(
    self: T_Processing,
    freq_weighting: str | None = "Z",
    time_weighting: str = "Fast",
    dB: bool = False,  # noqa: N803
) -> T_Processing:
    """Compute a frequency- and time-weighted RMS or reference-relative level.

    The selected frequency weighting is applied first. Squared samples are
    then smoothed by a first-order exponential filter using 125 ms (Fast)
    or 1 s (Slow). With ``dB=False`` the square root is returned in the
    calibrated input unit. With ``dB=True`` the result is
    ``10 * log10(max(smoothed_power / channel_ref**2, 1e-20))``, bounded
    below by -200 dB. A Pa channel whose reference is ``2e-5 Pa`` yields
    dB SPL; an uncalibrated channel yields relative dB re 1 input unit.

    This method validates the implemented filters and time constants, not
    the complete tolerance, detector, calibration, or directional-response
    requirements of an IEC/JIS sound-level meter.

    Args:
        freq_weighting: Implemented frequency-weighting curve: ``"A"``,
            ``"C"``, or flat ``"Z"``. ``None`` is treated as ``"Z"``.
        time_weighting: Exponential time constant: ``"Fast"`` (125 ms) or
            ``"Slow"`` (1 s).
        dB: Return level relative to the channel reference when ``True``;
            otherwise return linear time-weighted RMS.

    Returns:
        New lazy ChannelFrame with shape ``(n_channels, n_samples)`` and
            the input sampling rate. Linear output retains the physical channel
            unit; dB output encodes its original reference in the channel unit
            (for example, ``dB SPL re 2e-05 Pa``). The input Frame remains
            unchanged and the result preserves metadata while extending
            lineage.

    Raises:
        ValueError: If the frequency/time weighting or channel references
            are invalid for the input Frame.
    """
    ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values(
        require_non_default=True,
    )

    params = {
        "freq_weighting": freq_weighting,
        "time_weighting": time_weighting,
        "dB": dB,
        **({"ref": ref_values} if ref_values else {}),
    }
    if dB:
        result = cast(Any, self)._apply_level_operation("sound_level", **params)
    else:
        result = self._apply_named_operation("sound_level", **params)
    return cast(T_Processing, result)

channel_difference(other_channel=0)

Compute index-wise differences between channels.

channel_difference subtracts the selected reference channel from each channel at the current array indices. It does not compare per-channel source_time_offset values and does not perform source-time alignment.

Parameters:

Name Type Description Default
other_channel int | str

Index or label of the reference channel. Default is 0.

0

Returns:

Type Description
T_Processing

New ChannelFrame containing the channel difference with the input source-time offsets preserved.

Source code in wandas/frames/mixins/channel_processing_mixin.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
@recipe_operation("wandas.audio.channel_difference")
def channel_difference(self: T_Processing, other_channel: int | str = 0) -> T_Processing:
    """Compute index-wise differences between channels.

    ``channel_difference`` subtracts the selected reference channel from
    each channel at the current array indices. It does not compare
    per-channel ``source_time_offset`` values and does not perform
    source-time alignment.

    Args:
        other_channel: Index or label of the reference channel. Default is 0.

    Returns:
        New ChannelFrame containing the channel difference with the input
            source-time offsets preserved.
    """
    # label2index is a method of BaseFrame
    if isinstance(other_channel, str) and hasattr(self, "label2index"):
        other_channel = self.label2index(other_channel)

    result = self._apply_named_operation("channel_difference", other_channel=other_channel)
    return cast(T_Processing, result)

resampling(target_sr, **kwargs)

Resample audio data.

Parameters:

Name Type Description Default
target_sr float

Target sampling rate (Hz)

required
**kwargs Any

Additional resampling parameters

{}

Returns:

Type Description
T_Processing

Resampled ChannelFrame

Source code in wandas/frames/mixins/channel_processing_mixin.py
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
@recipe_operation("wandas.audio.resampling")
def resampling(
    self: T_Processing,
    target_sr: float,
    **kwargs: Any,
) -> T_Processing:
    """Resample audio data.

    Args:
        target_sr: Target sampling rate (Hz)
        **kwargs: Additional resampling parameters

    Returns:
        Resampled ChannelFrame
    """
    return cast(
        T_Processing,
        self._apply_named_operation(
            "resampling",
            target_sr=target_sr,
            **kwargs,
        ),
    )

hpss_harmonic(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract harmonic components using HPSS (Harmonic-Percussive Source Separation).

This method separates the harmonic (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size HpssKernelSize

Median filter size for HPSS.

31
power HpssFloatLike

Exponent for the Weiner filter used in HPSS.

2
margin HpssMargin

Margin size for the separation.

1
n_fft HpssIntLike

Size of FFT window.

2048
hop_length HpssIntLike | None

Hop length for STFT.

None
win_length HpssIntLike | None

Window length for STFT.

None
window Any

Window type for STFT.

'hann'
center bool

If True, center the frames.

True
pad_mode str

Padding mode for STFT.

'constant'

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
@recipe_operation("wandas.audio.hpss_harmonic")
def hpss_harmonic(
    self: T_Processing,
    kernel_size: HpssKernelSize = 31,
    power: HpssFloatLike = 2,
    margin: HpssMargin = 1,
    n_fft: HpssIntLike = 2048,
    hop_length: HpssIntLike | None = None,
    win_length: HpssIntLike | None = None,
    window: "Any" = "hann",
    center: bool = True,
    pad_mode: "str" = "constant",
) -> T_Processing:
    """
    Extract harmonic components using HPSS
     (Harmonic-Percussive Source Separation).

    This method separates the harmonic (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.
        n_fft: Size of FFT window.
        hop_length: Hop length for STFT.
        win_length: Window length for STFT.
        window: Window type for STFT.
        center: If True, center the frames.
        pad_mode: Padding mode for STFT.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    return cast(
        T_Processing,
        self._hpss(
            "hpss_harmonic",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        ),
    )

hpss_percussive(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract percussive components using HPSS (Harmonic-Percussive Source Separation).

This method separates the percussive (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size HpssKernelSize

Median filter size for HPSS.

31
power HpssFloatLike

Exponent for the Weiner filter used in HPSS.

2
margin HpssMargin

Margin size for the separation.

1

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
@recipe_operation("wandas.audio.hpss_percussive")
def hpss_percussive(
    self: T_Processing,
    kernel_size: HpssKernelSize = 31,
    power: HpssFloatLike = 2,
    margin: HpssMargin = 1,
    n_fft: HpssIntLike = 2048,
    hop_length: HpssIntLike | None = None,
    win_length: HpssIntLike | None = None,
    window: "Any" = "hann",
    center: bool = True,
    pad_mode: "str" = "constant",
) -> T_Processing:
    """
    Extract percussive components using HPSS
    (Harmonic-Percussive Source Separation).

    This method separates the percussive (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    return cast(
        T_Processing,
        self._hpss(
            "hpss_percussive",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        ),
    )

loudness_zwtv(field_type='free')

Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of non-stationary signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying loudness values in sones. Each channel is processed independently. The output sampling rate is adjusted based on the loudness calculation time resolution (typically ~500 Hz for 2ms steps).

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate loudness for a signal:

>>> import wandas as wd
>>> signal = wd.read("audio.wav")
>>> loudness = signal.loudness_zwtv(field_type="free")
>>> loudness.plot(title="Time-varying Loudness")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwtv(field_type="free")
>>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")
Notes
  • The output contains time-varying loudness values in sones
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • The time resolution is approximately 2ms (determined by the algorithm)
  • For multi-channel signals, loudness is calculated per channel
  • The output sampling rate is updated to reflect the time resolution

Time axis convention: The time axis in the returned frame represents the start time of each 2ms analysis step. This differs slightly from the MoSQITo library, which uses the center time of each step. For example:

  • wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
  • MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

The difference is very small (~1ms) and does not affect the loudness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
@recipe_operation("wandas.audio.loudness_zwtv")
def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
    """
    Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of non-stationary signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        New ChannelFrame containing time-varying loudness values in sones.
            Each channel is processed independently.
            The output sampling rate is adjusted based on the loudness
            calculation time resolution (typically ~500 Hz for 2ms steps).

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate loudness for a signal:
        >>> import wandas as wd
        >>> signal = wd.read("audio.wav")
        >>> loudness = signal.loudness_zwtv(field_type="free")
        >>> loudness.plot(title="Time-varying Loudness")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwtv(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

    Notes:
        - The output contains time-varying loudness values in sones
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - The time resolution is approximately 2ms (determined by the algorithm)
        - For multi-channel signals, loudness is calculated per channel
        - The output sampling rate is updated to reflect the time resolution

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 2ms analysis step. This differs slightly from the MoSQITo
        library, which uses the center time of each step. For example:

        - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
        - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

        The difference is very small (~1ms) and does not affect the loudness
        values themselves. This design choice ensures consistency with
        wandas's time axis convention across all frame types.

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    result = self._apply_named_operation("loudness_zwtv", field_type=field_type)

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)

loudness_zwst(field_type='free')

Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of stationary (steady) signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

This method is suitable for analyzing steady sounds such as fan noise, constant machinery sounds, or other stationary signals.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
NDArrayReal

Loudness values in sones, one per channel. Shape: (n_channels,)

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate steady-state loudness for a fan noise:

>>> import wandas as wd
>>> signal = wd.read("fan_noise.wav")
>>> loudness = signal.loudness_zwst(field_type="free")
>>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
>>> print(f"Mean loudness: {loudness.mean():.2f} sones")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwst(field_type="free")
>>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
>>> print(f"Free field: {loudness_free[0]:.2f} sones")
>>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")
Notes
  • Returns a 1D array with one loudness value per channel
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • For multi-channel signals, loudness is calculated independently per channel
  • This method is designed for stationary signals (constant sounds)
  • For time-varying signals, use loudness_zwtv() instead
  • Similar to the rms property, returns NDArrayReal for consistency
References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
def loudness_zwst(self: ProcessingFrameProtocol, field_type: str = "free") -> "NDArrayReal":
    """
    Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of stationary (steady) signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    This method is suitable for analyzing steady sounds such as fan noise,
    constant machinery sounds, or other stationary signals.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        Loudness values in sones, one per channel. Shape: (n_channels,)

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate steady-state loudness for a fan noise:
        >>> import wandas as wd
        >>> signal = wd.read("fan_noise.wav")
        >>> loudness = signal.loudness_zwst(field_type="free")
        >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
        >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwst(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
        >>> print(f"Free field: {loudness_free[0]:.2f} sones")
        >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

    Notes:
        - Returns a 1D array with one loudness value per channel
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - For multi-channel signals, loudness is calculated independently
          per channel
        - This method is designed for stationary signals (constant sounds)
        - For time-varying signals, use loudness_zwtv() instead
        - Similar to the rms property, returns NDArrayReal for consistency

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    from wandas.processing.psychoacoustic import LoudnessZwst

    operation = LoudnessZwst(self.sampling_rate, field_type=field_type)
    return self._compute_scalar_metric(operation)

roughness_dw(overlap=0.5)

Calculate time-varying roughness using Daniel and Weber method.

Roughness is a psychoacoustic metric that quantifies the perceived harshness or roughness of a sound, measured in asper. This method implements the Daniel & Weber (1997) standard calculation.

The calculation follows the standard formula: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying roughness values in asper. The output sampling rate depends on the overlap parameter.

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Calculate roughness for a motor noise:

>>> import wandas as wd
>>> signal = wd.read("motor_noise.wav")
>>> roughness = signal.roughness_dw(overlap=0.5)
>>> roughness.plot(ylabel="Roughness [asper]")

Analyze roughness statistics:

>>> mean_roughness = roughness.data.mean()
>>> max_roughness = roughness.data.max()
>>> print(f"Mean: {mean_roughness:.2f} asper")
>>> print(f"Max: {max_roughness:.2f} asper")

Compare before and after modification:

>>> before = wd.read("motor_before.wav").roughness_dw()
>>> after = wd.read("motor_after.wav").roughness_dw()
>>> improvement = before.data.mean() - after.data.mean()
>>> print(f"Roughness reduction: {improvement:.2f} asper")
Notes
  • Returns a ChannelFrame with time-varying roughness values
  • Typical roughness values: 0-2 asper for most sounds
  • Higher values indicate rougher, harsher sounds
  • For multi-channel signals, roughness is calculated independently per channel
  • This is the standard-compliant total roughness (R)
  • For detailed Bark-band analysis, use roughness_dw_spec() instead

Time axis convention: The time axis in the returned frame represents the start time of each 200ms analysis window. This differs from the MoSQITo library, which uses the center time of each window. For example:

  • wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
  • MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

The difference is constant (half the window duration = 100ms) and does not affect the roughness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
@recipe_operation("wandas.audio.roughness_dw")
def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
    """Calculate time-varying roughness using Daniel and Weber method.

    Roughness is a psychoacoustic metric that quantifies the perceived
    harshness or roughness of a sound, measured in asper. This method
    implements the Daniel & Weber (1997) standard calculation.

    The calculation follows the standard formula:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        New ChannelFrame containing time-varying roughness values in asper.
            The output sampling rate depends on the overlap parameter.

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Calculate roughness for a motor noise:
        >>> import wandas as wd
        >>> signal = wd.read("motor_noise.wav")
        >>> roughness = signal.roughness_dw(overlap=0.5)
        >>> roughness.plot(ylabel="Roughness [asper]")

        Analyze roughness statistics:
        >>> mean_roughness = roughness.data.mean()
        >>> max_roughness = roughness.data.max()
        >>> print(f"Mean: {mean_roughness:.2f} asper")
        >>> print(f"Max: {max_roughness:.2f} asper")

        Compare before and after modification:
        >>> before = wd.read("motor_before.wav").roughness_dw()
        >>> after = wd.read("motor_after.wav").roughness_dw()
        >>> improvement = before.data.mean() - after.data.mean()
        >>> print(f"Roughness reduction: {improvement:.2f} asper")

    Notes:
        - Returns a ChannelFrame with time-varying roughness values
        - Typical roughness values: 0-2 asper for most sounds
        - Higher values indicate rougher, harsher sounds
        - For multi-channel signals, roughness is calculated independently
          per channel
        - This is the standard-compliant total roughness (R)
        - For detailed Bark-band analysis, use roughness_dw_spec() instead

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 200ms analysis window. This differs from the MoSQITo library,
        which uses the center time of each window. For example:

        - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
        - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

        The difference is constant (half the window duration = 100ms) and
        does not affect the roughness values themselves. This design choice
        ensures consistency with wandas's time axis convention across all
        frame types.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """
    logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
    result = self._apply_named_operation("roughness_dw", overlap=overlap)
    return cast(T_Processing, result)

roughness_dw_spec(overlap=0.5)

Calculate specific roughness with Bark-band frequency information.

This method returns detailed roughness analysis data organized by Bark frequency bands over time, allowing for frequency-specific roughness analysis. It uses the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
RoughnessFrame

RoughnessFrame containing: - data: Specific roughness by Bark band, shape (47, n_time) for mono or (n_channels, 47, n_time) for multi-channel - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5) - time: Time axis for each analysis frame - overlap: Overlap coefficient used - plot(): Method for Bark-Time heatmap visualization

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Analyze frequency-specific roughness:

>>> import wandas as wd
>>> import numpy as np
>>> signal = wd.read("motor.wav")
>>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
>>>
>>> # Plot Bark-Time heatmap
>>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
>>>
>>> # Find dominant Bark band
>>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
>>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
>>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
>>>
>>> # Extract specific Bark band time series
>>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
>>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
>>>
>>> # Verify standard formula
>>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
>>> # This should match signal.roughness_dw(overlap=0.5).data
Notes
  • Returns a RoughnessFrame (not ChannelFrame)
  • Contains 47 Bark bands from 0.5 to 23.5 Bark
  • Each Bark band corresponds to a critical band of hearing
  • Useful for identifying which frequencies contribute most to roughness
  • The specific roughness can be integrated to obtain total roughness
  • For simple time-series analysis, use roughness_dw() instead

Time axis convention: The time axis represents the start time of each 200ms analysis window, consistent with roughness_dw() and other wandas methods.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
@recipe_operation("wandas.audio.roughness_dw_spec")
def roughness_dw_spec(self: ProcessingFrameProtocol, overlap: float = 0.5) -> "RoughnessFrame":
    """Calculate specific roughness with Bark-band frequency information.

    This method returns detailed roughness analysis data organized by
    Bark frequency bands over time, allowing for frequency-specific
    roughness analysis. It uses the Daniel & Weber (1997) method.

    The relationship between total roughness and specific roughness:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        RoughnessFrame containing:
            - data: Specific roughness by Bark band, shape (47, n_time)
                    for mono or (n_channels, 47, n_time) for multi-channel
            - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
            - time: Time axis for each analysis frame
            - overlap: Overlap coefficient used
            - plot(): Method for Bark-Time heatmap visualization

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Analyze frequency-specific roughness:
        >>> import wandas as wd
        >>> import numpy as np
        >>> signal = wd.read("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>>
        >>> # Plot Bark-Time heatmap
        >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
        >>>
        >>> # Find dominant Bark band
        >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
        >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
        >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
        >>>
        >>> # Extract specific Bark band time series
        >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
        >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
        >>>
        >>> # Verify standard formula
        >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
        >>> # This should match signal.roughness_dw(overlap=0.5).data

    Notes:
        - Returns a RoughnessFrame (not ChannelFrame)
        - Contains 47 Bark bands from 0.5 to 23.5 Bark
        - Each Bark band corresponds to a critical band of hearing
        - Useful for identifying which frequencies contribute most to roughness
        - The specific roughness can be integrated to obtain total roughness
        - For simple time-series analysis, use roughness_dw() instead

        **Time axis convention:**
        The time axis represents the start time of each 200ms analysis
        window, consistent with roughness_dw() and other wandas methods.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """

    params = {"overlap": overlap}
    operation_name = "roughness_dw_spec"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance via factory
    operation = create_operation(operation_name, self.sampling_rate, **params)

    # Apply processing lazily to the effective Dask data.
    r_spec_dask = operation.process(self._effective_data)

    # Get metadata updates (sampling rate, bark_axis)
    metadata_updates = operation.get_metadata_updates()

    # Build metadata
    new_metadata = {**self.metadata, **params}

    # Extract bark_axis with proper type handling
    bark_axis_value = metadata_updates.get("bark_axis")
    if bark_axis_value is None:
        raise ValueError("Operation did not provide bark_axis in metadata")

    # Create RoughnessFrame. operation.get_metadata_updates() should provide
    # sampling_rate and bark_axis
    lineage = cast(Any, self)._required_semantic_lineage()
    roughness_frame = RoughnessFrame(
        data=r_spec_dask,
        sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
        bark_axis=bark_axis_value,
        overlap=overlap,
        label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
        metadata=new_metadata,
        channel_metadata=cast(Any, self)._metadata_after_analysis(),
        channel_ids=cast(Any, self)._channel_ids,
        source_time_offset=cast(Any, self).source_time_offset,
        lineage=lineage,
        previous=cast("BaseFrame[NDArrayReal]", self),
    )

    logger.debug(
        "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
        operation_name,
        r_spec_dask.shape,
        roughness_frame.sampling_rate,
    )

    return roughness_frame

fade(fade_ms=50)

Apply symmetric fade-in and fade-out to the signal using Tukey window.

This method applies a symmetric fade-in and fade-out envelope to the signal using a Tukey (tapered cosine) window. The fade duration is the same for both the beginning and end of the signal.

Parameters:

Name Type Description Default
fade_ms float

Fade duration in milliseconds for each end of the signal. The total fade duration is 2 * fade_ms. Default is 50 ms. Must be positive and less than half the signal duration.

50

Returns:

Type Description
T_Processing

New ChannelFrame containing the faded signal

Raises:

Type Description
ValueError

If fade_ms is negative or too long for the signal

Examples:

>>> import wandas as wd
>>> signal = wd.read("audio.wav")
>>> # Apply 10ms fade-in and fade-out
>>> faded = signal.fade(fade_ms=10.0)
>>> # Apply very short fade (almost no effect)
>>> faded_short = signal.fade(fade_ms=0.1)
Notes
  • Uses SciPy's Tukey window for smooth fade transitions
  • Fade is applied symmetrically to both ends of the signal
  • The Tukey window alpha parameter is computed automatically based on the fade duration and signal length
  • For multi-channel signals, the same fade envelope is applied to all channels
  • Lazy evaluation is preserved - computation occurs only when needed
Source code in wandas/frames/mixins/channel_processing_mixin.py
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
@recipe_operation("wandas.audio.fade")
def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
    """Apply symmetric fade-in and fade-out to the signal using Tukey window.

    This method applies a symmetric fade-in and fade-out envelope to the signal
    using a Tukey (tapered cosine) window. The fade duration is the same for
    both the beginning and end of the signal.

    Args:
        fade_ms: Fade duration in milliseconds for each end of the signal.
            The total fade duration is 2 * fade_ms. Default is 50 ms.
            Must be positive and less than half the signal duration.

    Returns:
        New ChannelFrame containing the faded signal

    Raises:
        ValueError: If fade_ms is negative or too long for the signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read("audio.wav")
        >>> # Apply 10ms fade-in and fade-out
        >>> faded = signal.fade(fade_ms=10.0)
        >>> # Apply very short fade (almost no effect)
        >>> faded_short = signal.fade(fade_ms=0.1)

    Notes:
        - Uses SciPy's Tukey window for smooth fade transitions
        - Fade is applied symmetrically to both ends of the signal
        - The Tukey window alpha parameter is computed automatically
          based on the fade duration and signal length
        - For multi-channel signals, the same fade envelope is applied
          to all channels
        - Lazy evaluation is preserved - computation occurs only when needed
    """
    logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
    result = self._apply_named_operation("fade", fade_ms=fade_ms)
    return cast(T_Processing, result)

sharpness_din(weighting='din', field_type='free')

Calculate sharpness using DIN 45692 method.

This method computes the time-varying sharpness of the signal according to DIN 45692 standard, which quantifies the perceived sharpness of sounds.

Parameters:

Name Type Description Default
weighting str

str, default="din". Weighting type for sharpness calculation. Options: - 'din': DIN 45692 method - 'aures': Aures method - 'bismarck': Bismarck method - 'fastl': Fastl method

'din'
field_type str

str, default="free". Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions)

'free'

Returns:

Name Type Description
T_Processing T_Processing

New ChannelFrame containing sharpness time series in acum. The output sampling rate is approximately 500 Hz (2ms time steps).

Raises:

Type Description
ValueError

If the signal sampling rate is not supported by the algorithm.

Examples:

>>> import wandas as wd
>>> signal = wd.read("sharp_sound.wav")
>>> sharpness = signal.sharpness_din(weighting="din", field_type="free")
>>> print(f"Mean sharpness: {sharpness.data.mean():.2f} acum")
Notes
  • Sharpness is measured in acum (acum = 1 when the sound has the

same sharpness as a 2 kHz narrow-band noise at 60 dB SPL) - The calculation uses MoSQITo's implementation of DIN 45692 - Output sampling rate is fixed at 500 Hz regardless of input rate - For multi-channel signals, sharpness is calculated per channel

References

.. [1] DIN 45692:2009, "Measurement technique for the simulation of the auditory sensation of sharpness"

Source code in wandas/frames/mixins/channel_processing_mixin.py
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
@recipe_operation("wandas.audio.sharpness_din")
def sharpness_din(
    self: T_Processing,
    weighting: str = "din",
    field_type: str = "free",
) -> T_Processing:
    """Calculate sharpness using DIN 45692 method.

    This method computes the time-varying sharpness of the signal
    according to DIN 45692 standard, which quantifies the perceived
    sharpness of sounds.

    Args:
        weighting: str, default="din". Weighting type for sharpness calculation. Options:
            - 'din': DIN 45692 method
            - 'aures': Aures method
            - 'bismarck': Bismarck method
            - 'fastl': Fastl method
        field_type: str, default="free". Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)

    Returns:
        T_Processing: New ChannelFrame containing sharpness time series in acum.
            The output sampling rate is approximately 500 Hz (2ms time steps).

    Raises:
        ValueError: If the signal sampling rate is not supported by the algorithm.

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read("sharp_sound.wav")
        >>> sharpness = signal.sharpness_din(weighting="din", field_type="free")
        >>> print(f"Mean sharpness: {sharpness.data.mean():.2f} acum")

    Notes:
        - Sharpness is measured in acum (acum = 1 when the sound has the
      same sharpness as a 2 kHz narrow-band noise at 60 dB SPL)
        - The calculation uses MoSQITo's implementation of DIN 45692
        - Output sampling rate is fixed at 500 Hz regardless of input rate
        - For multi-channel signals, sharpness is calculated per channel

    References:
        .. [1] DIN 45692:2009, "Measurement technique for the simulation of the
           auditory sensation of sharpness"
    """
    logger.debug(
        "Setting up sharpness DIN calculation with weighting=%s, field_type=%s (lazy)",
        weighting,
        field_type,
    )
    result = self._apply_named_operation(
        "sharpness_din",
        weighting=weighting,
        field_type=field_type,
    )
    return cast(T_Processing, result)

sharpness_din_st(weighting='din', field_type='free')

Calculate steady-state sharpness using DIN 45692 method.

This method computes the steady-state sharpness of the signal according to DIN 45692 standard, which quantifies the perceived sharpness of stationary sounds.

Parameters:

Name Type Description Default
weighting str

str, default="din". Weighting type for sharpness calculation. Options: - 'din': DIN 45692 method - 'aures': Aures method - 'bismarck': Bismarck method - 'fastl': Fastl method

'din'
field_type str

str, default="free". Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions)

'free'

Returns:

Name Type Description
NDArrayReal NDArrayReal

Sharpness values in acum, one per channel. Shape: (n_channels,)

Raises:

Type Description
ValueError

If the signal sampling rate is not supported by the algorithm.

Examples:

>>> import wandas as wd
>>> signal = wd.read("constant_tone.wav")
>>> sharpness = signal.sharpness_din_st(weighting="din", field_type="free")
>>> print(f"Steady-state sharpness: {sharpness[0]:.2f} acum")
Notes
  • Sharpness is measured in acum (acum = 1 when the sound has the

same sharpness as a 2 kHz narrow-band noise at 60 dB SPL) - The calculation uses MoSQITo's implementation of DIN 45692 - Output is a single value per channel, suitable for stationary signals - For multi-channel signals, sharpness is calculated per channel

References

.. [1] DIN 45692:2009, "Measurement technique for the simulation of the auditory sensation of sharpness"

Source code in wandas/frames/mixins/channel_processing_mixin.py
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
def sharpness_din_st(
    self: ProcessingFrameProtocol,
    weighting: str = "din",
    field_type: str = "free",
) -> "NDArrayReal":
    """Calculate steady-state sharpness using DIN 45692 method.

    This method computes the steady-state sharpness of the signal
    according to DIN 45692 standard, which quantifies the perceived
    sharpness of stationary sounds.

    Args:
        weighting: str, default="din". Weighting type for sharpness calculation. Options:
            - 'din': DIN 45692 method
            - 'aures': Aures method
            - 'bismarck': Bismarck method
            - 'fastl': Fastl method
        field_type: str, default="free". Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)

    Returns:
        NDArrayReal: Sharpness values in acum, one per channel. Shape: (n_channels,)

    Raises:
        ValueError: If the signal sampling rate is not supported by the algorithm.

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read("constant_tone.wav")
        >>> sharpness = signal.sharpness_din_st(weighting="din", field_type="free")
        >>> print(f"Steady-state sharpness: {sharpness[0]:.2f} acum")

    Notes:
        - Sharpness is measured in acum (acum = 1 when the sound has the
      same sharpness as a 2 kHz narrow-band noise at 60 dB SPL)
        - The calculation uses MoSQITo's implementation of DIN 45692
        - Output is a single value per channel, suitable for stationary signals
        - For multi-channel signals, sharpness is calculated per channel

    References:
        .. [1] DIN 45692:2009, "Measurement technique for the simulation of the
           auditory sensation of sharpness"
    """
    from wandas.processing.psychoacoustic import SharpnessDinSt

    operation = SharpnessDinSt(self.sampling_rate, weighting=weighting, field_type=field_type)
    return self._compute_scalar_metric(operation)

wandas.frames.mixins.channel_transform_mixin.ChannelTransformMixin

Mixin providing methods related to frequency transformations.

This mixin provides operations related to frequency analysis and transformations such as FFT, STFT, and Welch method.

Source code in wandas/frames/mixins/channel_transform_mixin.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
class ChannelTransformMixin:
    """Mixin providing methods related to frequency transformations.

    This mixin provides operations related to frequency analysis and
    transformations such as FFT, STFT, and Welch method.
    """

    @property
    def _as_base_frame(self: TransformFrameProtocol) -> "BaseFrame[Any]":
        """Cast self to BaseFrame for use as ``previous`` in new frames."""
        return cast(BaseFrame[Any], self)

    @recipe_operation("wandas.audio.cepstrum", version=2)
    def cepstrum(
        self: TransformFrameProtocol,
        n_fft: int | None = None,
        window: str = "hann",
        floor: float = 1e-12,
    ) -> "CepstralFrame":
        """Calculate the normalized real cepstrum of each channel.

        Args:
            n_fft: int, optional. FFT size. ``None`` uses the current sample count. Smaller values
                truncate and larger values zero-pad the analysis input.
            window: str, default="hann". SciPy window name applied before the FFT.
            floor: float, default=1e-12. Positive finite floor applied to normalized magnitude before ``log``.

        Returns:
            CepstralFrame: New lazy real coefficients with dimensions
                ``(channel, quefrency)``. Channel metadata, IDs, user metadata,
                sampling rate, and source-time offsets are preserved.

        Raises:
            TypeError: If the input is complex or a parameter has the wrong type.
            ValueError: If ``n_fft`` or ``floor`` is invalid.

        Notes:
            The method only builds a Dask graph. Accessing ``data``, calling
            ``compute()``, or plotting materializes the coefficients.

        Examples:
            >>> cepstrum = frame.cepstrum(n_fft=2048, window="hann")
            >>> envelope = cepstrum.lifter(0.002).to_spectral_envelope()
        """
        from wandas.processing import Cepstrum, create_operation

        _validate_real_cepstrum_input(self._effective_data)
        operation = cast(
            "Cepstrum",
            create_operation(
                "cepstrum",
                self.sampling_rate,
                n_fft=n_fft,
                window=window,
                floor=floor,
            ),
        )
        return cast(Any, self)._cepstrum_with_operation(operation)

    @recipe_operation("wandas.audio.cepstrum", version=1)
    def _cepstrum_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int | None = None,
        window: str = "hann",
        floor: float = 1e-12,
    ) -> "CepstralFrame":
        """Replay the released Recipe v1 cepstrum preparation contract."""
        _validate_real_cepstrum_input(self._effective_data)
        from wandas.processing.cepstral import _RecipeCepstrumV1

        operation = _RecipeCepstrumV1(
            self.sampling_rate,
            n_fft=n_fft,
            window=window,
            floor=floor,
        )
        return cast(Any, self)._cepstrum_with_operation(operation)

    def _cepstrum_with_operation(self: TransformFrameProtocol, operation: Any) -> "CepstralFrame":
        """Build a CepstralFrame for the public or released Recipe operation."""
        from wandas.frames.cepstral import CepstralFrame

        cepstrum_data = operation.process(self._effective_data)
        resolved_n_fft = int(cepstrum_data.shape[-1])
        return CepstralFrame(
            data=cepstrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=resolved_n_fft,
            window=operation.window,
            label=f"Cepstrum of {self.label}",
            metadata=self.metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            previous=self._as_base_frame,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=cast(Any, self)._required_semantic_lineage(),
        )

    @recipe_operation("wandas.audio.fft", version=2)
    def fft(self: TransformFrameProtocol, n_fft: int | None = None, window: str = "hann") -> "SpectralFrame":
        """Calculate a one-sided peak-amplitude FFT spectrum.

        The signal is truncated or zero-padded to ``n_fft``, windowed, and
        normalized by the window's coherent gain. Values retain each channel's
        physical unit. Positive-frequency bins other than Nyquist are doubled,
        so an on-bin sinusoid's magnitude equals its peak amplitude. Graph
        construction remains lazy.

        Args:
            n_fft: Number of FFT points. By default, use the current sample
                count exactly.
            window: Window type. Default is "hann".

        Returns:
            A lazy SpectralFrame containing complex peak-amplitude values.
        """
        from wandas.processing import FFT, create_operation

        _n_fft = int(self._effective_data.shape[-1]) if n_fft is None else n_fft
        params = {"n_fft": _n_fft, "window": window}
        operation_name = "fft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("FFT", operation)
        return cast(Any, self)._fft_with_operation(operation, n_fft=_n_fft)

    @recipe_operation("wandas.audio.fft", version=1)
    def _fft_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int | None = None,
        window: str = "hann",
    ) -> "SpectralFrame":
        """Replay the released Recipe v1 FFT preparation contract."""
        from wandas.processing.spectral import _RecipeFFTV1

        _n_fft = int(self._effective_data.shape[-1]) if n_fft is None else n_fft
        operation = _RecipeFFTV1(self.sampling_rate, n_fft=_n_fft, window=window)
        return cast(Any, self)._fft_with_operation(operation, n_fft=_n_fft)

    def _fft_with_operation(
        self: TransformFrameProtocol,
        operation: Any,
        *,
        n_fft: int,
    ) -> "SpectralFrame":
        """Build a SpectralFrame for the public or released Recipe operation."""
        from wandas.frames.spectral import SpectralFrame

        spectrum_data = operation.process(self._effective_data)
        logger.debug("Created new SpectralFrame with FFT operation added to graph")
        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata=self.metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=cast(Any, self)._required_semantic_lineage(),
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.welch", version=2)
    def welch(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate a Welch-averaged one-sided peak-amplitude spectrum.

        Segment power spectra are averaged and converted to peak amplitude.
        Values retain each channel's physical unit and are not power spectral
        density or expressed per hertz. ``SpectralFrame.dB`` therefore uses the
        amplitude rule ``20 * log10(amplitude / channel_ref)``. Graph
        construction remains lazy.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is ``win_length // 4``.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            average: Method for averaging segments. Default is "mean".

        Returns:
            A lazy SpectralFrame containing real peak-amplitude values.
        """
        from wandas.processing import Welch, create_operation

        params = {
            "n_fft": n_fft or win_length,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "average": average,
        }
        operation_name = "welch"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("Welch", operation)
        return cast(Any, self)._welch_with_operation(operation)

    @recipe_operation("wandas.audio.welch", version=1)
    def _welch_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Replay the released Recipe v1 Welch scaling contract."""
        from wandas.processing.spectral import _RecipeWelchV1

        # Released v1 captured raw n_fft but executed the public truthy fallback.
        resolved_n_fft = cast(int, n_fft or win_length)
        operation = _RecipeWelchV1(
            self.sampling_rate,
            n_fft=resolved_n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            average=average,
        )
        return cast(Any, self)._welch_with_operation(operation)

    def _welch_with_operation(self: TransformFrameProtocol, operation: Any) -> "SpectralFrame":
        """Build a SpectralFrame for the public or released Recipe operation."""
        from wandas.frames.spectral import SpectralFrame

        spectrum_data = operation.process(self._effective_data)
        logger.debug("Created new SpectralFrame with Welch operation added to graph")
        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata=self.metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=cast(Any, self)._required_semantic_lineage(),
            previous=self._as_base_frame,
        )

    @recipe_operation(
        "wandas.audio.noct_spectrum",
        validate_params=validate_noct_recipe_params,
    )
    def noct_spectrum(
        self: TransformFrameProtocol,
        fmin: float = 25,
        fmax: float = 20000,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> "NOctFrame":
        """Calculate N-octave band spectrum.

        Each output value is the RMS amplitude in one fractional-octave band
        and retains the input channel's physical unit. ``NOctFrame.dB`` applies
        ``20 * log10(band_rms / channel_ref)``.

        Args:
            fmin: Minimum center frequency (Hz). Default is 25 Hz.
            fmax: Maximum center frequency (Hz). Default is 20000 Hz.
            n: Band division (1: octave, 3: 1/3 octave). Default is 3.
            G: Exact center-frequency ratio convention. Use 10 for base
                ``10**(3/10)`` or 2 for base 2. Default is 10.
            fr: Reference frequency (Hz). Default is 1000 Hz.

        Returns:
            A lazy NOctFrame containing per-band RMS amplitudes.
        """
        from wandas.processing import NOctSpectrum, create_operation

        from ..noct import NOctFrame

        params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
        operation_name = "noct_spectrum"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("NOctSpectrum", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._effective_data)

        logger.debug(f"Created new SpectralFrame with operation {operation_name} added to graph")

        lineage = cast(Any, self)._required_semantic_lineage()
        return NOctFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=G,
            fr=fr,
            label=f"1/{n}Oct of {self.label}",
            metadata=self.metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=lineage,
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.stft")
    def stft(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ) -> "SpectrogramFrame":
        """Calculate a one-sided peak-amplitude Short-Time Fourier Transform.

        Each time frame is normalized by its window's coherent gain. Complex
        values retain the input physical unit; an on-bin sinusoid's magnitude
        is its peak amplitude.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is ``n_fft // 4``.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".

        Returns:
            SpectrogramFrame containing STFT results
        """
        from wandas.processing import STFT, create_operation

        from ..spectrogram import SpectrogramFrame

        # Set hop length and window length
        _hop_length = hop_length if hop_length is not None else n_fft // 4
        _win_length = win_length if win_length is not None else n_fft

        params = {
            "n_fft": n_fft,
            "hop_length": _hop_length,
            "win_length": _win_length,
            "window": window,
        }
        operation_name = "stft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("STFT", operation)

        # Apply processing to data
        spectrogram_data = operation.process(self._effective_data)

        logger.debug(f"Created new SpectrogramFrame with operation {operation_name} added to graph")

        # Create new instance
        lineage = cast(Any, self)._required_semantic_lineage()
        return SpectrogramFrame(
            data=spectrogram_data,
            sampling_rate=self.sampling_rate,
            n_fft=n_fft,
            hop_length=_hop_length,
            win_length=_win_length,
            window=window,
            label=f"stft({self.label})",
            metadata=self.metadata,
            channel_metadata=cast(Any, self)._metadata_after_analysis(),
            channel_ids=cast(Any, self)._channel_ids,
            source_time_offset=cast(Any, self).source_time_offset,
            lineage=lineage,
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.coherence", version=2)
    def coherence(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ) -> "CoherenceFrame":
        """Calculate typed magnitude-squared coherence for every channel pair.

        The result is a :class:`CoherenceFrame` with flattened ``(pair,
        frequency)`` storage and output-major/input-minor pair order.  Its real
        raw values are dimensionless and lie in ``[0, 1]``; ``NaN`` is retained
        for undefined zero-energy bins.  This mathematical result contract is
        enforced by the numerical operation, not by scanning values in the
        :class:`CoherenceFrame` constructor.  Pair roles, source identity,
        domains, and row order are carried by immutable typed state, not labels
        or operation history.  See the spectral numerical contracts for the
        canonical mathematical definition.

        Sampling rate and user metadata are preserved.  Each pair's
        ``source_time_offset`` is derived from its input-role source offset, and
        input calibration is consumed before the pairwise operation.  Constructing
        the result remains Dask-lazy; accessing data or plotting is the
        materialization boundary.  Invalid spectral parameters or structural
        Frame state raise an actionable ``TypeError`` or ``ValueError`` instead
        of being silently coerced.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.

        Returns:
            CoherenceFrame whose public single-pair shape is ``(frequency,)`` and
            whose multi-pair shape is ``(pair, frequency)``.  Use ``.coherence``
            for the quantity-specific raw values.

        Example:
            ``coherence = frame.coherence(n_fft=1024, window="hann")``
        """
        from ..pairwise import CoherenceFrame

        return _cross_channel_spectral_transform(
            self,
            "coherence",
            "Coherence of",
            "$\\gamma_{{{out_label}, {in_label}}}$",
            CoherenceFrame,
            "coherence",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
        )

    @recipe_operation("wandas.audio.coherence", version=1)
    def _coherence_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ) -> "CoherenceFrame":
        """Replay the released coherence pair-label order."""
        from ..pairwise import CoherenceFrame

        return _cross_channel_spectral_transform(
            self,
            "coherence",
            "Coherence of",
            "$\\gamma_{{{in_label}, {out_label}}}$",
            CoherenceFrame,
            "coherence",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
        )

    @recipe_operation("wandas.audio.csd", version=2)
    def csd(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "CrossSpectralFrame":
        """Calculate a typed cross-spectral density matrix.

        The result is a :class:`CrossSpectralFrame` with flattened
        ``(pair, frequency)`` storage and output-major/input-minor pair order.
        Each raw complex row stores ``P_out_in = conj(X_input) * X_output``;
        pair domains provide the unit and reference, with ``/Hz`` included for
        ``scaling="density"``.  Pair roles and domains are immutable typed state;
        labels and operation history are display/provenance views only.  See the
        spectral numerical contracts for the canonical definition and scaling.

        Sampling rate and user metadata are preserved.  Pair
        ``source_time_offset`` uses the input-role source offset, and input
        calibration is consumed before constructing output metadata.  The result
        stays Dask-lazy until data, a property, or a plot is materialized.
        Invalid spectral parameters or domain/shape violations raise an actionable
        ``TypeError`` or ``ValueError``.  Use the quantity-specific ``magnitude``,
        ``phase``, and ``level_db`` properties; pairwise A-weighting is rejected.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            CrossSpectralFrame whose public single-pair shape is ``(frequency,)``
            and whose multi-pair shape is ``(pair, frequency)``.

        Example:
            ``spectrum = frame.csd(n_fft=1024, scaling="density")``
        """
        from ..pairwise import CrossSpectralFrame

        return _cross_channel_spectral_transform(
            self,
            "csd",
            "CSD of",
            "csd({out_label}, {in_label})",
            CrossSpectralFrame,
            "csd",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    @recipe_operation("wandas.audio.csd", version=1)
    def _csd_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "CrossSpectralFrame":
        """Replay the released CSD pair-label order."""
        from ..pairwise import CrossSpectralFrame

        return _cross_channel_spectral_transform(
            self,
            "csd",
            "CSD of",
            "csd({in_label}, {out_label})",
            CrossSpectralFrame,
            "csd",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    @recipe_operation("wandas.audio.transfer_function", version=2)
    def transfer_function(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "TransferFunctionFrame":
        """Calculate the canonical typed output/input transfer-function matrix.

        The v2 result is a :class:`TransferFunctionFrame` with flattened
        ``(pair, frequency)`` storage and output-major/input-minor pair order.  It
        stores ``H_out_in = P_out_in / P_in_in`` and carries the denominator
        definition, pair roles, unit/reference domain, and row order as immutable
        typed state.  Labels and operation history do not define its meaning; the
        released v1 denominator contract is replayed separately by the v1 Recipe
        handler.  See the spectral numerical contracts for the canonical formulas.

        Sampling rate and user metadata are preserved.  Pair
        ``source_time_offset`` uses the input-role source offset, and input
        calibration is consumed before output metadata is derived.  Construction
        remains Dask-lazy; accessing data, a property, or a plot materializes the
        requested values.  Invalid spectral parameters or shape/domain violations
        raise an actionable ``TypeError`` or ``ValueError``.  ``gain_db`` is
        available only after selecting dimensionless pairs; ``transfer_level_db``
        uses each pair's explicit reference ratio.  Pairwise A-weighting is
        rejected.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            TransferFunctionFrame whose public single-pair shape is ``(frequency,)``
            and whose multi-pair shape is ``(pair, frequency)``.

        Example:
            ``transfer = frame.transfer_function(n_fft=1024, scaling="spectrum")``
        """
        from ..pairwise import TransferFunctionFrame

        return _cross_channel_spectral_transform(
            self,
            "transfer_function",
            "Transfer function of",
            "$H_{{{out_label}, {in_label}}}$",
            TransferFunctionFrame,
            "transfer",
            "input",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    @recipe_operation("wandas.audio.transfer_function", version=1)
    def _transfer_function_recipe_v1(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "TransferFunctionFrame":
        """Replay the released transfer-function denominator contract."""
        from wandas.processing.spectral import _RecipeTransferFunctionV1

        from ..pairwise import TransferFunctionFrame

        operation = _RecipeTransferFunctionV1(
            self.sampling_rate,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )
        return _cross_channel_spectral_transform(
            self,
            "transfer_function",
            "Transfer function of",
            "$H_{{{in_label}, {out_label}}}$",
            TransferFunctionFrame,
            "transfer",
            "output",
            operation_override=operation,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

Functions

cepstrum(n_fft=None, window='hann', floor=1e-12)

Calculate the normalized real cepstrum of each channel.

Parameters:

Name Type Description Default
n_fft int | None

int, optional. FFT size. None uses the current sample count. Smaller values truncate and larger values zero-pad the analysis input.

None
window str

str, default="hann". SciPy window name applied before the FFT.

'hann'
floor float

float, default=1e-12. Positive finite floor applied to normalized magnitude before log.

1e-12

Returns:

Name Type Description
CepstralFrame CepstralFrame

New lazy real coefficients with dimensions (channel, quefrency). Channel metadata, IDs, user metadata, sampling rate, and source-time offsets are preserved.

Raises:

Type Description
TypeError

If the input is complex or a parameter has the wrong type.

ValueError

If n_fft or floor is invalid.

Notes

The method only builds a Dask graph. Accessing data, calling compute(), or plotting materializes the coefficients.

Examples:

>>> cepstrum = frame.cepstrum(n_fft=2048, window="hann")
>>> envelope = cepstrum.lifter(0.002).to_spectral_envelope()
Source code in wandas/frames/mixins/channel_transform_mixin.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@recipe_operation("wandas.audio.cepstrum", version=2)
def cepstrum(
    self: TransformFrameProtocol,
    n_fft: int | None = None,
    window: str = "hann",
    floor: float = 1e-12,
) -> "CepstralFrame":
    """Calculate the normalized real cepstrum of each channel.

    Args:
        n_fft: int, optional. FFT size. ``None`` uses the current sample count. Smaller values
            truncate and larger values zero-pad the analysis input.
        window: str, default="hann". SciPy window name applied before the FFT.
        floor: float, default=1e-12. Positive finite floor applied to normalized magnitude before ``log``.

    Returns:
        CepstralFrame: New lazy real coefficients with dimensions
            ``(channel, quefrency)``. Channel metadata, IDs, user metadata,
            sampling rate, and source-time offsets are preserved.

    Raises:
        TypeError: If the input is complex or a parameter has the wrong type.
        ValueError: If ``n_fft`` or ``floor`` is invalid.

    Notes:
        The method only builds a Dask graph. Accessing ``data``, calling
        ``compute()``, or plotting materializes the coefficients.

    Examples:
        >>> cepstrum = frame.cepstrum(n_fft=2048, window="hann")
        >>> envelope = cepstrum.lifter(0.002).to_spectral_envelope()
    """
    from wandas.processing import Cepstrum, create_operation

    _validate_real_cepstrum_input(self._effective_data)
    operation = cast(
        "Cepstrum",
        create_operation(
            "cepstrum",
            self.sampling_rate,
            n_fft=n_fft,
            window=window,
            floor=floor,
        ),
    )
    return cast(Any, self)._cepstrum_with_operation(operation)

fft(n_fft=None, window='hann')

Calculate a one-sided peak-amplitude FFT spectrum.

The signal is truncated or zero-padded to n_fft, windowed, and normalized by the window's coherent gain. Values retain each channel's physical unit. Positive-frequency bins other than Nyquist are doubled, so an on-bin sinusoid's magnitude equals its peak amplitude. Graph construction remains lazy.

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. By default, use the current sample count exactly.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectralFrame

A lazy SpectralFrame containing complex peak-amplitude values.

Source code in wandas/frames/mixins/channel_transform_mixin.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
@recipe_operation("wandas.audio.fft", version=2)
def fft(self: TransformFrameProtocol, n_fft: int | None = None, window: str = "hann") -> "SpectralFrame":
    """Calculate a one-sided peak-amplitude FFT spectrum.

    The signal is truncated or zero-padded to ``n_fft``, windowed, and
    normalized by the window's coherent gain. Values retain each channel's
    physical unit. Positive-frequency bins other than Nyquist are doubled,
    so an on-bin sinusoid's magnitude equals its peak amplitude. Graph
    construction remains lazy.

    Args:
        n_fft: Number of FFT points. By default, use the current sample
            count exactly.
        window: Window type. Default is "hann".

    Returns:
        A lazy SpectralFrame containing complex peak-amplitude values.
    """
    from wandas.processing import FFT, create_operation

    _n_fft = int(self._effective_data.shape[-1]) if n_fft is None else n_fft
    params = {"n_fft": _n_fft, "window": window}
    operation_name = "fft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("FFT", operation)
    return cast(Any, self)._fft_with_operation(operation, n_fft=_n_fft)

welch(n_fft=2048, hop_length=None, win_length=None, window='hann', average='mean')

Calculate a Welch-averaged one-sided peak-amplitude spectrum.

Segment power spectra are averaged and converted to peak amplitude. Values retain each channel's physical unit and are not power spectral density or expressed per hertz. SpectralFrame.dB therefore uses the amplitude rule 20 * log10(amplitude / channel_ref). Graph construction remains lazy.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is win_length // 4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

A lazy SpectralFrame containing real peak-amplitude values.

Source code in wandas/frames/mixins/channel_transform_mixin.py
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
@recipe_operation("wandas.audio.welch", version=2)
def welch(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate a Welch-averaged one-sided peak-amplitude spectrum.

    Segment power spectra are averaged and converted to peak amplitude.
    Values retain each channel's physical unit and are not power spectral
    density or expressed per hertz. ``SpectralFrame.dB`` therefore uses the
    amplitude rule ``20 * log10(amplitude / channel_ref)``. Graph
    construction remains lazy.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is ``win_length // 4``.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        average: Method for averaging segments. Default is "mean".

    Returns:
        A lazy SpectralFrame containing real peak-amplitude values.
    """
    from wandas.processing import Welch, create_operation

    params = {
        "n_fft": n_fft or win_length,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "average": average,
    }
    operation_name = "welch"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("Welch", operation)
    return cast(Any, self)._welch_with_operation(operation)

noct_spectrum(fmin=25, fmax=20000, n=3, G=10, fr=1000)

Calculate N-octave band spectrum.

Each output value is the RMS amplitude in one fractional-octave band and retains the input channel's physical unit. NOctFrame.dB applies 20 * log10(band_rms / channel_ref).

Parameters:

Name Type Description Default
fmin float

Minimum center frequency (Hz). Default is 25 Hz.

25
fmax float

Maximum center frequency (Hz). Default is 20000 Hz.

20000
n int

Band division (1: octave, 3: 1/3 octave). Default is 3.

3
G int

Exact center-frequency ratio convention. Use 10 for base 10**(3/10) or 2 for base 2. Default is 10.

10
fr int

Reference frequency (Hz). Default is 1000 Hz.

1000

Returns:

Type Description
NOctFrame

A lazy NOctFrame containing per-band RMS amplitudes.

Source code in wandas/frames/mixins/channel_transform_mixin.py
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
@recipe_operation(
    "wandas.audio.noct_spectrum",
    validate_params=validate_noct_recipe_params,
)
def noct_spectrum(
    self: TransformFrameProtocol,
    fmin: float = 25,
    fmax: float = 20000,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
) -> "NOctFrame":
    """Calculate N-octave band spectrum.

    Each output value is the RMS amplitude in one fractional-octave band
    and retains the input channel's physical unit. ``NOctFrame.dB`` applies
    ``20 * log10(band_rms / channel_ref)``.

    Args:
        fmin: Minimum center frequency (Hz). Default is 25 Hz.
        fmax: Maximum center frequency (Hz). Default is 20000 Hz.
        n: Band division (1: octave, 3: 1/3 octave). Default is 3.
        G: Exact center-frequency ratio convention. Use 10 for base
            ``10**(3/10)`` or 2 for base 2. Default is 10.
        fr: Reference frequency (Hz). Default is 1000 Hz.

    Returns:
        A lazy NOctFrame containing per-band RMS amplitudes.
    """
    from wandas.processing import NOctSpectrum, create_operation

    from ..noct import NOctFrame

    params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
    operation_name = "noct_spectrum"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("NOctSpectrum", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._effective_data)

    logger.debug(f"Created new SpectralFrame with operation {operation_name} added to graph")

    lineage = cast(Any, self)._required_semantic_lineage()
    return NOctFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        fmin=fmin,
        fmax=fmax,
        n=n,
        G=G,
        fr=fr,
        label=f"1/{n}Oct of {self.label}",
        metadata=self.metadata,
        channel_metadata=cast(Any, self)._metadata_after_analysis(),
        channel_ids=cast(Any, self)._channel_ids,
        source_time_offset=cast(Any, self).source_time_offset,
        lineage=lineage,
        previous=self._as_base_frame,
    )

stft(n_fft=2048, hop_length=None, win_length=None, window='hann')

Calculate a one-sided peak-amplitude Short-Time Fourier Transform.

Each time frame is normalized by its window's coherent gain. Complex values retain the input physical unit; an on-bin sinusoid's magnitude is its peak amplitude.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft // 4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectrogramFrame

SpectrogramFrame containing STFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
@recipe_operation("wandas.audio.stft")
def stft(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
) -> "SpectrogramFrame":
    """Calculate a one-sided peak-amplitude Short-Time Fourier Transform.

    Each time frame is normalized by its window's coherent gain. Complex
    values retain the input physical unit; an on-bin sinusoid's magnitude
    is its peak amplitude.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is ``n_fft // 4``.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".

    Returns:
        SpectrogramFrame containing STFT results
    """
    from wandas.processing import STFT, create_operation

    from ..spectrogram import SpectrogramFrame

    # Set hop length and window length
    _hop_length = hop_length if hop_length is not None else n_fft // 4
    _win_length = win_length if win_length is not None else n_fft

    params = {
        "n_fft": n_fft,
        "hop_length": _hop_length,
        "win_length": _win_length,
        "window": window,
    }
    operation_name = "stft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("STFT", operation)

    # Apply processing to data
    spectrogram_data = operation.process(self._effective_data)

    logger.debug(f"Created new SpectrogramFrame with operation {operation_name} added to graph")

    # Create new instance
    lineage = cast(Any, self)._required_semantic_lineage()
    return SpectrogramFrame(
        data=spectrogram_data,
        sampling_rate=self.sampling_rate,
        n_fft=n_fft,
        hop_length=_hop_length,
        win_length=_win_length,
        window=window,
        label=f"stft({self.label})",
        metadata=self.metadata,
        channel_metadata=cast(Any, self)._metadata_after_analysis(),
        channel_ids=cast(Any, self)._channel_ids,
        source_time_offset=cast(Any, self).source_time_offset,
        lineage=lineage,
        previous=self._as_base_frame,
    )

coherence(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant')

Calculate typed magnitude-squared coherence for every channel pair.

The result is a :class:CoherenceFrame with flattened (pair, frequency) storage and output-major/input-minor pair order. Its real raw values are dimensionless and lie in [0, 1]; NaN is retained for undefined zero-energy bins. This mathematical result contract is enforced by the numerical operation, not by scanning values in the :class:CoherenceFrame constructor. Pair roles, source identity, domains, and row order are carried by immutable typed state, not labels or operation history. See the spectral numerical contracts for the canonical mathematical definition.

Sampling rate and user metadata are preserved. Each pair's source_time_offset is derived from its input-role source offset, and input calibration is consumed before the pairwise operation. Constructing the result remains Dask-lazy; accessing data or plotting is the materialization boundary. Invalid spectral parameters or structural Frame state raise an actionable TypeError or ValueError instead of being silently coerced.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'

Returns:

Type Description
CoherenceFrame

CoherenceFrame whose public single-pair shape is (frequency,) and

CoherenceFrame

whose multi-pair shape is (pair, frequency). Use .coherence

CoherenceFrame

for the quantity-specific raw values.

Example

coherence = frame.coherence(n_fft=1024, window="hann")

Source code in wandas/frames/mixins/channel_transform_mixin.py
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
@recipe_operation("wandas.audio.coherence", version=2)
def coherence(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
) -> "CoherenceFrame":
    """Calculate typed magnitude-squared coherence for every channel pair.

    The result is a :class:`CoherenceFrame` with flattened ``(pair,
    frequency)`` storage and output-major/input-minor pair order.  Its real
    raw values are dimensionless and lie in ``[0, 1]``; ``NaN`` is retained
    for undefined zero-energy bins.  This mathematical result contract is
    enforced by the numerical operation, not by scanning values in the
    :class:`CoherenceFrame` constructor.  Pair roles, source identity,
    domains, and row order are carried by immutable typed state, not labels
    or operation history.  See the spectral numerical contracts for the
    canonical mathematical definition.

    Sampling rate and user metadata are preserved.  Each pair's
    ``source_time_offset`` is derived from its input-role source offset, and
    input calibration is consumed before the pairwise operation.  Constructing
    the result remains Dask-lazy; accessing data or plotting is the
    materialization boundary.  Invalid spectral parameters or structural
    Frame state raise an actionable ``TypeError`` or ``ValueError`` instead
    of being silently coerced.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.

    Returns:
        CoherenceFrame whose public single-pair shape is ``(frequency,)`` and
        whose multi-pair shape is ``(pair, frequency)``.  Use ``.coherence``
        for the quantity-specific raw values.

    Example:
        ``coherence = frame.coherence(n_fft=1024, window="hann")``
    """
    from ..pairwise import CoherenceFrame

    return _cross_channel_spectral_transform(
        self,
        "coherence",
        "Coherence of",
        "$\\gamma_{{{out_label}, {in_label}}}$",
        CoherenceFrame,
        "coherence",
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        detrend=detrend,
    )

csd(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate a typed cross-spectral density matrix.

The result is a :class:CrossSpectralFrame with flattened (pair, frequency) storage and output-major/input-minor pair order. Each raw complex row stores P_out_in = conj(X_input) * X_output; pair domains provide the unit and reference, with /Hz included for scaling="density". Pair roles and domains are immutable typed state; labels and operation history are display/provenance views only. See the spectral numerical contracts for the canonical definition and scaling.

Sampling rate and user metadata are preserved. Pair source_time_offset uses the input-role source offset, and input calibration is consumed before constructing output metadata. The result stays Dask-lazy until data, a property, or a plot is materialized. Invalid spectral parameters or domain/shape violations raise an actionable TypeError or ValueError. Use the quantity-specific magnitude, phase, and level_db properties; pairwise A-weighting is rejected.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
CrossSpectralFrame

CrossSpectralFrame whose public single-pair shape is (frequency,)

CrossSpectralFrame

and whose multi-pair shape is (pair, frequency).

Example

spectrum = frame.csd(n_fft=1024, scaling="density")

Source code in wandas/frames/mixins/channel_transform_mixin.py
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
@recipe_operation("wandas.audio.csd", version=2)
def csd(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "CrossSpectralFrame":
    """Calculate a typed cross-spectral density matrix.

    The result is a :class:`CrossSpectralFrame` with flattened
    ``(pair, frequency)`` storage and output-major/input-minor pair order.
    Each raw complex row stores ``P_out_in = conj(X_input) * X_output``;
    pair domains provide the unit and reference, with ``/Hz`` included for
    ``scaling="density"``.  Pair roles and domains are immutable typed state;
    labels and operation history are display/provenance views only.  See the
    spectral numerical contracts for the canonical definition and scaling.

    Sampling rate and user metadata are preserved.  Pair
    ``source_time_offset`` uses the input-role source offset, and input
    calibration is consumed before constructing output metadata.  The result
    stays Dask-lazy until data, a property, or a plot is materialized.
    Invalid spectral parameters or domain/shape violations raise an actionable
    ``TypeError`` or ``ValueError``.  Use the quantity-specific ``magnitude``,
    ``phase``, and ``level_db`` properties; pairwise A-weighting is rejected.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        CrossSpectralFrame whose public single-pair shape is ``(frequency,)``
        and whose multi-pair shape is ``(pair, frequency)``.

    Example:
        ``spectrum = frame.csd(n_fft=1024, scaling="density")``
    """
    from ..pairwise import CrossSpectralFrame

    return _cross_channel_spectral_transform(
        self,
        "csd",
        "CSD of",
        "csd({out_label}, {in_label})",
        CrossSpectralFrame,
        "csd",
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )

transfer_function(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate the canonical typed output/input transfer-function matrix.

The v2 result is a :class:TransferFunctionFrame with flattened (pair, frequency) storage and output-major/input-minor pair order. It stores H_out_in = P_out_in / P_in_in and carries the denominator definition, pair roles, unit/reference domain, and row order as immutable typed state. Labels and operation history do not define its meaning; the released v1 denominator contract is replayed separately by the v1 Recipe handler. See the spectral numerical contracts for the canonical formulas.

Sampling rate and user metadata are preserved. Pair source_time_offset uses the input-role source offset, and input calibration is consumed before output metadata is derived. Construction remains Dask-lazy; accessing data, a property, or a plot materializes the requested values. Invalid spectral parameters or shape/domain violations raise an actionable TypeError or ValueError. gain_db is available only after selecting dimensionless pairs; transfer_level_db uses each pair's explicit reference ratio. Pairwise A-weighting is rejected.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
TransferFunctionFrame

TransferFunctionFrame whose public single-pair shape is (frequency,)

TransferFunctionFrame

and whose multi-pair shape is (pair, frequency).

Example

transfer = frame.transfer_function(n_fft=1024, scaling="spectrum")

Source code in wandas/frames/mixins/channel_transform_mixin.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
@recipe_operation("wandas.audio.transfer_function", version=2)
def transfer_function(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "TransferFunctionFrame":
    """Calculate the canonical typed output/input transfer-function matrix.

    The v2 result is a :class:`TransferFunctionFrame` with flattened
    ``(pair, frequency)`` storage and output-major/input-minor pair order.  It
    stores ``H_out_in = P_out_in / P_in_in`` and carries the denominator
    definition, pair roles, unit/reference domain, and row order as immutable
    typed state.  Labels and operation history do not define its meaning; the
    released v1 denominator contract is replayed separately by the v1 Recipe
    handler.  See the spectral numerical contracts for the canonical formulas.

    Sampling rate and user metadata are preserved.  Pair
    ``source_time_offset`` uses the input-role source offset, and input
    calibration is consumed before output metadata is derived.  Construction
    remains Dask-lazy; accessing data, a property, or a plot materializes the
    requested values.  Invalid spectral parameters or shape/domain violations
    raise an actionable ``TypeError`` or ``ValueError``.  ``gain_db`` is
    available only after selecting dimensionless pairs; ``transfer_level_db``
    uses each pair's explicit reference ratio.  Pairwise A-weighting is
    rejected.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        TransferFunctionFrame whose public single-pair shape is ``(frequency,)``
        and whose multi-pair shape is ``(pair, frequency)``.

    Example:
        ``transfer = frame.transfer_function(n_fft=1024, scaling="spectrum")``
    """
    from ..pairwise import TransferFunctionFrame

    return _cross_channel_spectral_transform(
        self,
        "transfer_function",
        "Transfer function of",
        "$H_{{{out_label}, {in_label}}}$",
        TransferFunctionFrame,
        "transfer",
        "input",
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )

wandas.frames.mixins.spectral_properties_mixin.SpectralPropertiesMixin

Shared magnitude, phase, squared-magnitude, and level properties.

Host classes must provide data (computed array), _data (Dask array), _channel_metadata, and freqs. The operation that created the host defines the stored quantity and unit.

NumPy properties use the same channel-axis convention as data: a single-channel SpectralFrame returns (frequency,) and a single-channel SpectrogramFrame returns (frequency, time); multiple channels retain a leading channel axis. Plotting restores that axis only at its boundary when it needs channel-first input. dBA uses the documented internal _data.ndim (2 for spectra, 3 for spectrograms) to locate the public frequency axis.

Source code in wandas/frames/mixins/spectral_properties_mixin.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
class SpectralPropertiesMixin:
    """Shared magnitude, phase, squared-magnitude, and level properties.

    Host classes must provide ``data`` (computed array),
    ``_data`` (Dask array), ``_channel_metadata``, and ``freqs``.
    The operation that created the host defines the stored quantity and unit.

    NumPy properties use the same channel-axis convention as ``data``: a
    single-channel ``SpectralFrame`` returns ``(frequency,)`` and a
    single-channel ``SpectrogramFrame`` returns ``(frequency, time)``; multiple
    channels retain a leading channel axis. Plotting restores that axis only at
    its boundary when it needs channel-first input. ``dBA`` uses the documented
    internal ``_data.ndim`` (2 for spectra, 3 for spectrograms) to locate the
    public frequency axis.
    """

    # -- read-only properties reused by SpectralFrame & SpectrogramFrame --

    @property
    def magnitude(self: Any) -> NDArrayReal:
        """Absolute magnitude of the stored spectral quantity."""
        result: NDArrayReal = np.abs(self.data)
        return result

    @property
    def phase(self: Any) -> NDArrayReal:
        """Phase angles in radians."""
        result: NDArrayReal = np.angle(self.data)
        return result

    @property
    def power(self: Any) -> NDArrayReal:
        """Squared magnitude, a compatibility property that is not a PSD."""
        mag: NDArrayReal = np.abs(self.data)
        result: NDArrayReal = mag**2
        return result

    @property
    def dB(self: Any) -> NDArrayReal:  # noqa: N802
        """Magnitude level: ``20 * log10(magnitude / channel_ref)``.

        For the canonical FFT, STFT, and Welch amplitude quantities, this is
        an amplitude level.
        """
        mag: NDArrayReal = np.abs(self.data)
        return _ref_weighted_db_public_shape(mag, self._channel_metadata)

    @property
    def dBA(self: Any) -> NDArrayReal:  # noqa: N802
        """A-weighted magnitude level relative to each channel reference.

        For the canonical FFT, STFT, and Welch amplitude quantities, this is
        an A-weighted amplitude level.
        """
        level: NDArrayReal = self.dB
        weighted: NDArrayReal = a_weighting_db(frequencies=self.freqs, min_db=None)
        frequency_axis = level.ndim - 2 if self._data.ndim == 3 else level.ndim - 1
        if level.shape[frequency_axis] != weighted.shape[0]:
            raise ValueError(
                "A-weighting frequency axis does not match spectral data\n"
                f"  Data shape: {level.shape}\n"
                f"  Frequencies: {weighted.shape[0]}"
            )
        weight_shape = [1] * level.ndim
        weight_shape[frequency_axis] = weighted.shape[0]
        result: NDArrayReal = level + weighted.reshape(weight_shape)
        return result

Attributes

magnitude property

Absolute magnitude of the stored spectral quantity.

phase property

Phase angles in radians.

power property

Squared magnitude, a compatibility property that is not a PSD.

dB property

Magnitude level: 20 * log10(magnitude / channel_ref).

For the canonical FFT, STFT, and Welch amplitude quantities, this is an amplitude level.

dBA property

A-weighted magnitude level relative to each channel reference.

For the canonical FFT, STFT, and Welch amplitude quantities, this is an A-weighted amplitude level.