Skip to content

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

The wandas.frames module provides various data frame classes for manipulating and representing audio data. wandas.frames モジュールは、オーディオデータの操作と表現のための様々なデータフレームクラスを提供します。

ChannelFrame

ChannelFrame is the basic frame for handling time-domain waveform data. ChannelFrameは時間領域の波形データを扱うための基本的なフレームです。

Frame annotations are updated immutably with with_label(), with_metadata(), or with_channel_extra(). Use with_source_time_offset() for portable source-time intent and rename_channels() on any Frame family. Direct mutation is unsupported; public state getters return detached snapshots. String channel lookup always means a channel name. Use frame.channels.by_id() for explicit stable-ID lookup.

ChannelCalibration.with_unit() preserves the factor and resets ref to the new unit's default. Chain .with_unit(...).with_ref(...) for a custom reference.

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
 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
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
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: Compatibility/debug pointer to the immediate prior frame;
                not the provenance source of truth.
            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.
        """
        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 RMS (Root Mean Square) value 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 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.

        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.

        Note:
            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]":
        """Generate an RMS plot.

        Args:
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot.
            overlay: Whether to overlay the plot on the existing axis.
            Aw: Apply A-weighting.
            **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 [V]", alpha=0.8, color="blue")
        """
        kwargs = kwargs or {}
        ylabel = kwargs.pop("ylabel", "RMS")
        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.

        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 audio data.

        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
            if header is not None:
                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:
                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:
            cf = ChannelFrame(
                data=dask_array,
                sampling_rate=sr,
                label=frame_label,
                metadata={"_source_file": source_file} if source_file is not None else None,
                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 the data (lazy loading).

        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

        Example:
            >>> 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

    @recipe_operation(
        "wandas.channel.add_channel",
        binding_patterns=_ADD_CHANNEL_INPUT_PATTERNS,
        capture=_capture_channel_input("data"),
        handler=_add_channel_recipe,
    )
    def add_channel(
        self,
        data: "np.ndarray[Any, Any] | DaArray | ChannelFrame",
        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: Data to add as a new channel. Can be:
                - numpy array (1D or 2D)
                - dask array (1D or 2D)
                - ChannelFrame (channels will be added)
            label: Label for the new channel. If None, generates a default label.
                When data is a ChannelFrame, acts as a prefix: each channel in
                the input frame is renamed to ``"{label}_{original_label}"``.
                If None (the default), the original channel labels are used as-is.
            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 raw numpy or dask input.
                If None, raw input uses 0.0. When data is a ChannelFrame,
                offsets are taken from that frame and this argument must be None.

        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 type is not supported.

        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")
            >>> # Add another ChannelFrame's channels
            >>> cf2 = wd.read("audio2.wav")
            >>> cf_combined = cf.add_channel(cf2)
        """
        if isinstance(data, ChannelFrame):
            if source_time_offset is not None:
                raise ValueError(
                    "source_time_offset cannot be used when adding a ChannelFrame\n"
                    "  ChannelFrame input already carries per-channel offsets.\n"
                    "Pass raw ndarray or dask data to set an explicit offset."
                )
            if self.sampling_rate != data.sampling_rate:
                raise ValueError("sampling_rate mismatch")
            arr = _align_to_length(data._data, self.n_samples, align, data.n_samples)
            labels = self.labels
            new_labels: list[str] = []
            new_metadata_list = data._borrowed_channel_metadata_descriptors()
            for descriptor, chmeta in zip(new_metadata_list, data.channels, strict=True):
                new_label = f"{label}_{chmeta.label}" if label 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_id = self._next_channel_id(new_ids)
                new_ids.append(new_id)
            new_offsets = np.concatenate([self.source_time_offset, data.source_time_offset])
            return self._finalize_channel_update(
                new_data,
                new_chmeta,
                new_ids,
                new_offsets,
                lineage=self._required_semantic_lineage(),
            )
        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: ndarray/dask/ChannelFrame")
        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)]
        raw_source_time_offset = 0.0 if source_time_offset is None else source_time_offset
        new_channel_offsets = self._normalize_source_time_offset(raw_source_time_offset, arr.shape[0])
        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.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 RMS (Root Mean Square) value 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 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

NDArrayReal

for each channel.

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

NDArrayReal

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

Compatibility/debug pointer to the immediate prior frame; not the provenance source of truth.

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
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
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: Compatibility/debug pointer to the immediate prior frame;
            not the provenance source of truth.
        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
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
@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.

Source code in wandas/frames/channel.py
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
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.
    """
    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
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
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.

Note

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
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
@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.

    Note:
        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
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
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)

Generate an RMS plot.

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 A-weighting.

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 [V]", alpha=0.8, color="blue")
Source code in wandas/frames/channel.py
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
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]":
    """Generate an RMS plot.

    Args:
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot.
        overlay: Whether to overlay the plot on the existing axis.
        Aw: Apply A-weighting.
        **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 [V]", alpha=0.8, color="blue")
    """
    kwargs = kwargs or {}
    ylabel = kwargs.pop("ylabel", "RMS")
    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

list[Figure] | None

objects created for each channel. The list length equals the number of

list[Figure] | None

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
 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
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
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
@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
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
@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.

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 audio data.

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
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
@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.

    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 audio data.

    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
        if header is not None:
            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:
            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:
        cf = ChannelFrame(
            data=dask_array,
            sampling_rate=sr,
            label=frame_label,
            metadata={"_source_file": source_file} if source_file is not None else None,
            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
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
@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 the data (lazy loading).

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
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
@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 the data (lazy loading).

    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
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
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

Example

import wandas as wd cf = wd.load("audio_analysis.wdf")

Source code in wandas/frames/channel.py
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
@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

    Example:
        >>> 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 | ChannelFrame

Data to add as a new channel. Can be: - numpy array (1D or 2D) - dask array (1D or 2D) - ChannelFrame (channels will be added)

required
label str | None

Label for the new channel. If None, generates a default label. When data is a ChannelFrame, acts as a prefix: each channel in the input frame is renamed to "{label}_{original_label}". If None (the default), the original channel labels are used as-is.

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 raw numpy or dask input. If None, raw input uses 0.0. When data is a ChannelFrame, offsets are taken from that frame and this argument must be None.

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 type is not supported.

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")
>>> # Add another ChannelFrame's channels
>>> cf2 = wd.read("audio2.wav")
>>> cf_combined = cf.add_channel(cf2)
Source code in wandas/frames/channel.py
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
@recipe_operation(
    "wandas.channel.add_channel",
    binding_patterns=_ADD_CHANNEL_INPUT_PATTERNS,
    capture=_capture_channel_input("data"),
    handler=_add_channel_recipe,
)
def add_channel(
    self,
    data: "np.ndarray[Any, Any] | DaArray | ChannelFrame",
    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: Data to add as a new channel. Can be:
            - numpy array (1D or 2D)
            - dask array (1D or 2D)
            - ChannelFrame (channels will be added)
        label: Label for the new channel. If None, generates a default label.
            When data is a ChannelFrame, acts as a prefix: each channel in
            the input frame is renamed to ``"{label}_{original_label}"``.
            If None (the default), the original channel labels are used as-is.
        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 raw numpy or dask input.
            If None, raw input uses 0.0. When data is a ChannelFrame,
            offsets are taken from that frame and this argument must be None.

    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 type is not supported.

    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")
        >>> # Add another ChannelFrame's channels
        >>> cf2 = wd.read("audio2.wav")
        >>> cf_combined = cf.add_channel(cf2)
    """
    if isinstance(data, ChannelFrame):
        if source_time_offset is not None:
            raise ValueError(
                "source_time_offset cannot be used when adding a ChannelFrame\n"
                "  ChannelFrame input already carries per-channel offsets.\n"
                "Pass raw ndarray or dask data to set an explicit offset."
            )
        if self.sampling_rate != data.sampling_rate:
            raise ValueError("sampling_rate mismatch")
        arr = _align_to_length(data._data, self.n_samples, align, data.n_samples)
        labels = self.labels
        new_labels: list[str] = []
        new_metadata_list = data._borrowed_channel_metadata_descriptors()
        for descriptor, chmeta in zip(new_metadata_list, data.channels, strict=True):
            new_label = f"{label}_{chmeta.label}" if label 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_id = self._next_channel_id(new_ids)
            new_ids.append(new_id)
        new_offsets = np.concatenate([self.source_time_offset, data.source_time_offset])
        return self._finalize_channel_update(
            new_data,
            new_chmeta,
            new_ids,
            new_offsets,
            lineage=self._required_semantic_lineage(),
        )
    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: ndarray/dask/ChannelFrame")
    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)]
    raw_source_time_offset = 0.0 if source_time_offset is None else source_time_offset
    new_channel_offsets = self._normalize_source_time_offset(raw_source_time_offset, arr.shape[0])
    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(),
    )

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

ChannelFrame

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
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
@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(),
    )

Use the per-channel calibration learning app to configure known conversion factors without modifying the source frame. Calibrated physical values are available from frame.data as a NumPy array; users do not need to manage the internal array backend.

For a recorded reference event, use exactly one known scalar target:

microphone_calibration = microphone_reference.derive_calibration(
    target_level=94.0,
    unit="Pa",
)
acceleration_calibration = acceleration_reference.derive_calibration(
    target_rms=1.0,
    unit="m/s^2",
)
measurement = measurement.with_calibration(
    {**microphone_calibration, **acceleration_calibration}
)

The scalar is broadcast to every channel in that reference event. References with different targets, units, or recording times are separate events whose label mappings can be combined. Derivation always uses the current frame.data/frame.rms, including an existing factor, and does not inspect or change operation history. It requires unique non-empty labels. The reference and measurement recording chain—including amplifier gain—must represent the same physical scale.

get_channel(..., validate_query_keys: bool = True) parameter

  • validate_query_keys: When True (default), dict-style query arguments are validated against the known channel metadata fields and any existing extra keys. Unknown keys raise KeyError with the message "Unknown channel metadata key". Set to False to skip this pre-validation and allow queries that reference keys not present on the model; in that case, normal matching proceeds and a no-match will raise the usual KeyError for no results.

Source-time offsets and index-wise operations

source_time_offset records where each channel's local sample axis starts on the original source timeline. Binary frame operators such as frame_a + frame_b do not use this value for automatic alignment. They operate on the current array indices after verifying that sampling rate, channel count, and shape match.

Different source_time_offset values are allowed. The result inherits the left operand's source_time_offset, so frame_a + frame_b carries frame_a's source timeline. channel_difference() follows the same index-wise principle within one frame and preserves the input channel offsets.

When a workflow needs source-time alignment, trim or otherwise align frames explicitly before applying binary operators. A dedicated source-time alignment API may be added separately in the future.

SpectralFrame

SpectralFrame is a frame for handling frequency-domain data. SpectralFrameは周波数領域のデータを扱うためのフレームです。

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

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 Compatibility/debug pointer to the immediate prior frame; not the provenance source of truth.

Attributes

magnitude : NDArrayReal The magnitude spectrum of the data. phase : NDArrayReal The phase spectrum in radians. unwrapped_phase : NDArrayReal The unwrapped phase spectrum in radians. power : NDArrayReal The power spectrum (magnitude squared). dB : NDArrayReal The spectrum in decibels relative to channel reference values. dBA : NDArrayReal The A-weighted spectrum in decibels. 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 magnitude 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
 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
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.

    Parameters
    ----------
    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
        Compatibility/debug pointer to the immediate prior frame; not the
        provenance source of truth.

    Attributes
    ----------
    magnitude : NDArrayReal
        The magnitude spectrum of the data.
    phase : NDArrayReal
        The phase spectrum in radians.
    unwrapped_phase : NDArrayReal
        The unwrapped phase spectrum in radians.
    power : NDArrayReal
        The power spectrum (magnitude squared).
    dB : NDArrayReal
        The spectrum in decibels relative to channel reference values.
    dBA : NDArrayReal
        The A-weighted spectrum in decibels.
    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 magnitude 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.

        Parameters
        ----------
        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")
    def ifft(self) -> ChannelFrame:
        """
        Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

        This method transforms the frequency-domain data back to the time domain using
        the inverse FFT operation. The window function used in the forward FFT is
        taken into account to ensure proper reconstruction.

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

        """
        from ..processing import IFFT, create_operation
        from .channel import ChannelFrame

        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)
        # Apply processing to data
        time_series = operation.process(self._data)

        logger.debug(f"Created new SpectralFrame 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"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,
        )

    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")
    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.

        Parameters
        ----------
        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
            Reference band number.
        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.
        """
        if self.sampling_rate != 48000:
            raise ValueError("noct_synthesis can only be used with a sampling rate of 48000 Hz.")
        from ..processing import NOctSynthesis
        from .noct import NOctFrame

        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)
        operation = cast("NOctSynthesis", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(f"Created new SpectralFrame with operation {operation_name} 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.

        Parameters
        ----------
        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

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

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
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
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

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)

Source code in wandas/frames/spectral.py
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
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.

    Parameters
    ----------
    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()

Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

This method transforms the frequency-domain data back to the time domain using the inverse FFT operation. The window function used in the forward FFT is taken into account to ensure proper reconstruction.

Returns

ChannelFrame A new ChannelFrame containing the time-domain signal.

Source code in wandas/frames/spectral.py
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
@recipe_operation("wandas.spectral.ifft")
def ifft(self) -> ChannelFrame:
    """
    Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

    This method transforms the frequency-domain data back to the time domain using
    the inverse FFT operation. The window function used in the forward FFT is
    taken into account to ensure proper reconstruction.

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

    """
    from ..processing import IFFT, create_operation
    from .channel import ChannelFrame

    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)
    # Apply processing to data
    time_series = operation.process(self._data)

    logger.debug(f"Created new SpectralFrame 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"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,
    )

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.

Parameters

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 Reference band number. 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.

Source code in wandas/frames/spectral.py
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
@recipe_operation("wandas.spectral.noct_synthesis")
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.

    Parameters
    ----------
    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
        Reference band number.
    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.
    """
    if self.sampling_rate != 48000:
        raise ValueError("noct_synthesis can only be used with a sampling rate of 48000 Hz.")
    from ..processing import NOctSynthesis
    from .noct import NOctFrame

    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)
    operation = cast("NOctSynthesis", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(f"Created new SpectralFrame with operation {operation_name} 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,
    )

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

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.

Source code in wandas/frames/spectral.py
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
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.

    Parameters
    ----------
    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
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
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()

CepstralFrame

CepstralFrame represents a normalized real cepstrum on a quefrency axis. Start with the cepstral analysis guide for the typed ChannelFrame -> CepstralFrame -> SpectralFrame workflow. CepstralFrameは、ケフレンシー軸上の正規化された実ケプストラムを表します。型付きの ChannelFrame -> CepstralFrame -> SpectralFrameワークフローは ケプストラム解析ガイドを参照してください。

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

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 Compatibility/debug pointer to the immediate prior frame. 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()

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
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
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.

    Parameters
    ----------
    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
        Compatibility/debug pointer to the immediate prior frame.
    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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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, ylabel : str
            Axis labels.
        **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
 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
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

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")

Source code in wandas/frames/cepstral.py
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
@recipe_operation("wandas.cepstral.lifter")
def lifter(
    self,
    cutoff: float,
    mode: Literal["low", "high"] = "low",
) -> CepstralFrame:
    """Keep low- or high-quefrency coefficients.

    Parameters
    ----------
    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

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()

Source code in wandas/frames/cepstral.py
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
@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

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, ylabel : str Axis labels. **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()

Source code in wandas/frames/cepstral.py
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
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.

    Parameters
    ----------
    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, ylabel : str
        Axis labels.
    **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

CepstrogramFrame

CepstrogramFrame represents a real cepstrum at every STFT time frame with dimensions (channel, quefrency, time). It is created by SpectrogramFrame.cepstrum() and can reconstruct a time-varying spectral envelope. See the cepstral analysis guide. CepstrogramFrameは各STFT時間フレームの実ケプストラムを (channel, quefrency, time)で表します。SpectrogramFrame.cepstrum()から生成し、 時間変化するスペクトル包絡を再構成できます。

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

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 Compatibility/debug pointer to the immediate prior frame. 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()

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
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
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`.

    Parameters
    ----------
    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
        Compatibility/debug pointer to the immediate prior frame.
    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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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, ylabel : str
            Axis labels.
        cmap : str, default="RdBu_r"
            Matplotlib colormap for signed real coefficients.
        qmin, qmax : float, optional
            Display range on the quefrency axis in seconds.
        vmin, vmax : float, optional
            Shared color limits. When omitted, a symmetric robust range is
            estimated from the displayed coefficients except the dominant
            zero-quefrency row.
        **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
 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
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

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.

Source code in wandas/frames/cepstrogram.py
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
@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.

    Parameters
    ----------
    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

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.

Source code in wandas/frames/cepstrogram.py
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
@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

NotImplementedError Always raised. Materialize selected data when a tabular representation is required.

Source code in wandas/frames/cepstrogram.py
422
423
424
425
426
427
428
429
430
431
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

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, ylabel : str Axis labels. cmap : str, default="RdBu_r" Matplotlib colormap for signed real coefficients. qmin, qmax : float, optional Display range on the quefrency axis in seconds. vmin, vmax : float, optional Shared color limits. When omitted, a symmetric robust range is estimated from the displayed coefficients except the dominant zero-quefrency row. **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.

Source code in wandas/frames/cepstrogram.py
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
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.

    Parameters
    ----------
    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, ylabel : str
        Axis labels.
    cmap : str, default="RdBu_r"
        Matplotlib colormap for signed real coefficients.
    qmin, qmax : float, optional
        Display range on the quefrency axis in seconds.
    vmin, vmax : float, optional
        Shared color limits. When omitted, a symmetric robust range is
        estimated from the displayed coefficients except the dominant
        zero-quefrency row.
    **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)

SpectrogramFrame

SpectrogramFrame is a frame for handling time-frequency domain (spectrogram) data. SpectrogramFrameは時間-周波数領域(スペクトログラム)のデータを扱うフレームです。

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

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 Compatibility/debug pointer to the immediate prior frame; not the provenance source of truth.

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()

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
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
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.

    Parameters
    ----------
    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
        Compatibility/debug pointer to the immediate prior frame; not the
        provenance source of truth.

    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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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,
        )

    @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,
        )

    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: Reference to the previous frame in the processing chain.

        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

int The number of time frames in the spectrogram.

n_freq_bins property

Get the number of frequency bins.

Returns

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

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

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
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
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

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")

Source code in wandas/frames/spectrogram.py
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
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.

    Parameters
    ----------
    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

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")

Source code in wandas/frames/spectrogram.py
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
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.

    Parameters
    ----------
    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

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()

Source code in wandas/frames/spectrogram.py
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
@recipe_operation("wandas.spectrogram.cepstrum")
def cepstrum(self, floor: float = 1e-12) -> "CepstrogramFrame":
    """Calculate a real cepstrum independently at every time frame.

    Parameters
    ----------
    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

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
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
@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

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.

Source code in wandas/frames/spectrogram.py
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
@recipe_operation("wandas.spectrogram.get_frame_at")
def get_frame_at(self, time_idx: int) -> "SpectralFrame":
    """
    Extract spectral data at a specific time frame.

    Parameters
    ----------
    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,
    )

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

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
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
@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,
    )

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

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
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
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

NotImplementedError Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/spectrogram.py
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
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
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
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

Reference to the previous frame in the processing chain.

None

Returns:

Type Description
SpectrogramFrame

A new SpectrogramFrame containing the NumPy data.

Source code in wandas/frames/spectrogram.py
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
@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: Reference to the previous frame in the processing chain.

    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

NOctFrame

NOctFrame is a frame class for octave-band analysis. NOctFrameはオクターブバンド解析のためのフレームクラスです。

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 energy or power in each frequency band, following standard acoustical band definitions.

Parameters

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 Reference band number according to IEC 61260-1:2014. 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 Compatibility/debug pointer to the immediate prior frame; not the provenance source of truth.

Attributes

freqs : NDArrayReal The center frequencies of each band in Hz, calculated according to the standard fractional octave band definitions. dB : NDArrayReal The spectrum in decibels relative to channel reference values. 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 Reference band number. 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.
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
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
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 energy or power in each frequency band, following standard
    acoustical band definitions.

    Parameters
    ----------
    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
        Reference band number according to IEC 61260-1:2014.
    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
        Compatibility/debug pointer to the immediate prior frame; not the
        provenance source of truth.

    Attributes
    ----------
    freqs : NDArrayReal
        The center frequencies of each band in Hz, calculated according to
        the standard fractional octave band definitions.
    dB : NDArrayReal
        The spectrum in decibels relative to channel reference values.
    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
        Reference band number.
    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 the spectrum in decibels relative to each channel's reference value.

        The reference value for each channel is specified in its metadata.
        A minimum value of -120 dB is enforced to avoid numerical issues.

        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.

        Parameters
        ----------
        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: Reference band number
            - 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 the spectrum in decibels relative to each channel's reference value.

The reference value for each channel is specified in its metadata. A minimum value of -120 dB is enforced to avoid numerical issues.

Returns

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

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

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.

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
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
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

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)

Source code in wandas/frames/noct.py
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
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.

    Parameters
    ----------
    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

RoughnessFrame

RoughnessFrame is a frame class for psychoacoustic roughness analysis results. RoughnessFrameは心理音響ラフネス解析結果のためのフレームクラスです。

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

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 Compatibility/debug pointer to the immediate prior frame; not the provenance source of truth.

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.

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
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
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)

    Parameters
    ----------
    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
        Compatibility/debug pointer to the immediate prior frame; not the
        provenance source of truth.

    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."
        )

    def plot(
        self,
        plot_type: str = "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.

        Parameters
        ----------
        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, vmax : float, optional
            Color scale limits. 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.

        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")
        """
        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

NDArrayReal Computed data array.

n_bark_bands property

Number of Bark bands.

Returns

int Always 47 for the Daniel & Weber model.

n_time_points property

Number of time points in the roughness time series.

Returns

int Number of time frames in the analysis.

time property

Time axis based on sampling rate.

Returns

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
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
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

NotImplementedError Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/roughness.py
255
256
257
258
259
260
261
262
263
264
265
266
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

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, vmax : float, optional Color scale limits. 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.

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
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
def plot(
    self,
    plot_type: str = "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.

    Parameters
    ----------
    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, vmax : float, optional
        Color scale limits. 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.

    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")
    """
    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

Mixins

Mixins for extending frame functionality. フレームの機能を拡張するためのミックスインです。

ChannelProcessingMixin

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
  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
 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
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.

        Parameters
        ----------
        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 _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, according to the IEC 61672-1:2013 standard.

        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")
    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

        Raises:
            ValueError: If end time is earlier than start time
        """
        if end is None:
            end = self.duration
        if start > end:
            raise ValueError("start must be less than end")
        result = self._apply_named_operation("trim", start=start, end=end)
        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")
    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 the RMS trend of the signal.

        This method calculates the root mean square value over a sliding window.

        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: Whether to return RMS values in decibels. Default is False.
            Aw: Whether to apply A-weighting. Default is False.

        Returns:
            New ChannelFrame containing the RMS trend
        """
        # Access _channel_metadata to retrieve reference values
        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values()

        result = self._apply_named_operation(
            "rms_trend",
            frame_length=frame_length,
            hop_length=hop_length,
            ref=ref_values,
            dB=dB,
            Aw=Aw,
        )

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    @recipe_operation("wandas.audio.sound_level")
    def sound_level(
        self: T_Processing,
        freq_weighting: str | None = "Z",
        time_weighting: str = "Fast",
        dB: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Compute a time-weighted RMS trend or sound pressure level.

        Args:
            freq_weighting: Frequency weighting curve. Supported values are
                ``"A"``, ``"C"``, and ``"Z"``. ``None`` is treated as ``"Z"``.
            time_weighting: Time weighting characteristic. Supported values are
                ``"Fast"`` (125 ms) and ``"Slow"`` (1 s).
            dB: When ``True``, return sound level in dB relative to the channel
                reference. When ``False``, return the time-weighted RMS signal.

        Returns:
            New ChannelFrame containing the weighted time series.
        """
        ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values(
            require_non_default=True,
        )

        result = self._apply_named_operation(
            "sound_level",
            freq_weighting=freq_weighting,
            time_weighting=time_weighting,
            dB=dB,
            **({"ref": ref_values} if ref_values else {}),
        )
        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.

        Parameters
        ----------
        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.

        Parameters
        ----------
        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
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
@_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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
@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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
@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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
@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
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
@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
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.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, according to the IEC 61672-1:2013 standard.

Returns:

Type Description
T_Processing

New ChannelFrame containing the A-weighted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
335
336
337
338
339
340
341
342
343
344
345
346
@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, according to the IEC 61672-1:2013 standard.

    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
348
349
350
351
352
353
354
355
356
@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
358
359
360
361
362
363
364
365
366
367
368
369
@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
409
410
411
412
413
414
415
416
@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
418
419
420
421
422
423
424
425
@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

Raises:

Type Description
ValueError

If end time is earlier than start time

Source code in wandas/frames/mixins/channel_processing_mixin.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
@recipe_operation("wandas.audio.trim")
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

    Raises:
        ValueError: If end time is earlier than start time
    """
    if end is None:
        end = self.duration
    if start > end:
        raise ValueError("start must be less than end")
    result = self._apply_named_operation("trim", start=start, end=end)
    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
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
@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 the RMS trend of the signal.

This method calculates the root mean square value over a sliding window.

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

Whether to return RMS values in decibels. Default is False.

False
Aw bool

Whether to apply A-weighting. Default is False.

False

Returns:

Type Description
T_Processing

New ChannelFrame containing the RMS trend

Source code in wandas/frames/mixins/channel_processing_mixin.py
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.rms_trend")
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 the RMS trend of the signal.

    This method calculates the root mean square value over a sliding window.

    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: Whether to return RMS values in decibels. Default is False.
        Aw: Whether to apply A-weighting. Default is False.

    Returns:
        New ChannelFrame containing the RMS trend
    """
    # Access _channel_metadata to retrieve reference values
    ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values()

    result = self._apply_named_operation(
        "rms_trend",
        frame_length=frame_length,
        hop_length=hop_length,
        ref=ref_values,
        dB=dB,
        Aw=Aw,
    )

    # 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 time-weighted RMS trend or sound pressure level.

Parameters:

Name Type Description Default
freq_weighting str | None

Frequency weighting curve. Supported values are "A", "C", and "Z". None is treated as "Z".

'Z'
time_weighting str

Time weighting characteristic. Supported values are "Fast" (125 ms) and "Slow" (1 s).

'Fast'
dB bool

When True, return sound level in dB relative to the channel reference. When False, return the time-weighted RMS signal.

False

Returns:

Type Description
T_Processing

New ChannelFrame containing the weighted time series.

Source code in wandas/frames/mixins/channel_processing_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
@recipe_operation("wandas.audio.sound_level")
def sound_level(
    self: T_Processing,
    freq_weighting: str | None = "Z",
    time_weighting: str = "Fast",
    dB: bool = False,  # noqa: N803
) -> T_Processing:
    """Compute a time-weighted RMS trend or sound pressure level.

    Args:
        freq_weighting: Frequency weighting curve. Supported values are
            ``"A"``, ``"C"``, and ``"Z"``. ``None`` is treated as ``"Z"``.
        time_weighting: Time weighting characteristic. Supported values are
            ``"Fast"`` (125 ms) and ``"Slow"`` (1 s).
        dB: When ``True``, return sound level in dB relative to the channel
            reference. When ``False``, return the time-weighted RMS signal.

    Returns:
        New ChannelFrame containing the weighted time series.
    """
    ref_values = cast(ProcessingFrameProtocol, self)._get_ref_values(
        require_non_default=True,
    )

    result = self._apply_named_operation(
        "sound_level",
        freq_weighting=freq_weighting,
        time_weighting=time_weighting,
        dB=dB,
        **({"ref": ref_values} if ref_values else {}),
    )
    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

T_Processing

source-time offsets preserved.

Source code in wandas/frames/mixins/channel_processing_mixin.py
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
@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
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
@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
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
@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
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
@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.

T_Processing

Each channel is processed independently.

T_Processing

The output sampling rate is adjusted based on the loudness

T_Processing

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
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
@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
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
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.

T_Processing

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
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
@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
 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
@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
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
@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

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"

Source code in wandas/frames/mixins/channel_processing_mixin.py
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
@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.

    Parameters
    ----------
    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

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"

Source code in wandas/frames/mixins/channel_processing_mixin.py
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
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.

    Parameters
    ----------
    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)

ChannelTransformMixin

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
 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 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)

    def _cross_channel_spectral_transform(
        self: TransformFrameProtocol,
        operation_name: str,
        label_prefix: str,
        label_template: str,
        **params: Any,
    ) -> "SpectralFrame":
        """Shared implementation for cross-channel spectral transforms.

        Used by ``coherence``, ``csd``, and ``transfer_function``.
        """
        from wandas.processing import create_operation

        from ..spectral import SpectralFrame

        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        operation = create_operation(operation_name, self.sampling_rate, **params)
        result_data = operation.process(self._effective_data)

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

        channel_metadata = _build_cross_channel_metadata(
            self._channel_metadata,
            operation_name,
            label_template,
        )

        operation_params = operation.to_params()
        n_fft = operation_params["n_fft"]
        if isinstance(n_fft, bool) or not isinstance(n_fft, int):
            raise TypeError(
                f"Operation '{operation_name}' must provide a positive integer n_fft "
                f"to create a SpectralFrame, but got {type(n_fft).__name__}."
            )
        if n_fft <= 0:
            raise ValueError(
                f"Operation '{operation_name}' must provide a positive integer n_fft "
                f"to create a SpectralFrame, but got {n_fft}."
            )
        lineage = cast(Any, self)._required_semantic_lineage()
        return SpectralFrame(
            data=result_data,
            sampling_rate=self.sampling_rate,
            n_fft=n_fft,
            window=operation_params["window"],
            label=f"{label_prefix} {self.label}",
            metadata=self.metadata,
            channel_metadata=channel_metadata,
            source_time_offset=_build_cross_channel_source_time_offsets(cast(Any, self).source_time_offset),
            lineage=lineage,
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.cepstrum")
    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.

        Parameters
        ----------
        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.frames.cepstral import CepstralFrame
        from wandas.processing import Cepstrum, create_operation

        if np.issubdtype(self._effective_data.dtype, np.complexfloating):
            raise TypeError(
                "Cepstrum analysis requires real-valued input\n"
                f"  Got: {self._effective_data.dtype}\n"
                "  Expected: real time-domain samples\n"
                "Apply cepstrum() to a real ChannelFrame."
            )
        operation = cast(
            "Cepstrum",
            create_operation(
                "cepstrum",
                self.sampling_rate,
                n_fft=n_fft,
                window=window,
                floor=floor,
            ),
        )
        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")
    def fft(self: TransformFrameProtocol, n_fft: int | None = None, window: str = "hann") -> "SpectralFrame":
        """Calculate Fast Fourier Transform (FFT).

        Args:
            n_fft: Number of FFT points. Default is the next power of 2 of the data
                length.
            window: Window type. Default is "hann".

        Returns:
            SpectralFrame containing FFT results
        """
        from wandas.frames.spectral import SpectralFrame
        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)
        # 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 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=lineage,
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.welch")
    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 power spectral density using Welch's method.

        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".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing power spectral density
        """
        from wandas.frames.spectral import SpectralFrame
        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)
        # 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 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=lineage,
            previous=self._as_base_frame,
        )

    @recipe_operation("wandas.audio.noct_spectrum")
    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.

        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: Reference gain (dB). Default is 10 dB.
            fr: Reference frequency (Hz). Default is 1000 Hz.

        Returns:
            NOctFrame containing N-octave band spectrum
        """
        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 Short-Time Fourier Transform.

        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")
    def coherence(
        self: TransformFrameProtocol,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ) -> "SpectralFrame":
        """Calculate magnitude squared coherence.

        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:
            SpectralFrame containing magnitude squared coherence
        """
        return self._cross_channel_spectral_transform(
            "coherence",
            "Coherence of",
            "$\\gamma_{{{in_label}, {out_label}}}$",
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            detrend=detrend,
        )

    @recipe_operation("wandas.audio.csd")
    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",
    ) -> "SpectralFrame":
        """Calculate cross-spectral density matrix.

        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:
            SpectralFrame containing cross-spectral density matrix
        """
        return self._cross_channel_spectral_transform(
            "csd",
            "CSD of",
            "csd({in_label}, {out_label})",
            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")
    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",
    ) -> "SpectralFrame":
        """Calculate transfer function matrix.

        The transfer function represents the signal transfer characteristics between
        channels in the frequency domain and represents the input-output relationship
        of the system.

        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:
            SpectralFrame containing transfer function matrix
        """
        return self._cross_channel_spectral_transform(
            "transfer_function",
            "Transfer function of",
            "$H_{{{in_label}, {out_label}}}$",
            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

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()

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.cepstrum")
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.

    Parameters
    ----------
    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.frames.cepstral import CepstralFrame
    from wandas.processing import Cepstrum, create_operation

    if np.issubdtype(self._effective_data.dtype, np.complexfloating):
        raise TypeError(
            "Cepstrum analysis requires real-valued input\n"
            f"  Got: {self._effective_data.dtype}\n"
            "  Expected: real time-domain samples\n"
            "Apply cepstrum() to a real ChannelFrame."
        )
    operation = cast(
        "Cepstrum",
        create_operation(
            "cepstrum",
            self.sampling_rate,
            n_fft=n_fft,
            window=window,
            floor=floor,
        ),
    )
    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(),
    )

fft(n_fft=None, window='hann')

Calculate Fast Fourier Transform (FFT).

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. Default is the next power of 2 of the data length.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectralFrame

SpectralFrame containing FFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.fft")
def fft(self: TransformFrameProtocol, n_fft: int | None = None, window: str = "hann") -> "SpectralFrame":
    """Calculate Fast Fourier Transform (FFT).

    Args:
        n_fft: Number of FFT points. Default is the next power of 2 of the data
            length.
        window: Window type. Default is "hann".

    Returns:
        SpectralFrame containing FFT results
    """
    from wandas.frames.spectral import SpectralFrame
    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)
    # 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 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=lineage,
        previous=self._as_base_frame,
    )

welch(n_fft=2048, hop_length=None, win_length=None, window='hann', average='mean')

Calculate power spectral density using Welch's method.

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'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing power spectral density

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.welch")
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 power spectral density using Welch's method.

    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".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing power spectral density
    """
    from wandas.frames.spectral import SpectralFrame
    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)
    # 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 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=lineage,
        previous=self._as_base_frame,
    )

noct_spectrum(fmin=25, fmax=20000, n=3, G=10, fr=1000)

Calculate N-octave band spectrum.

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

Reference gain (dB). Default is 10 dB.

10
fr int

Reference frequency (Hz). Default is 1000 Hz.

1000

Returns:

Type Description
NOctFrame

NOctFrame containing N-octave band spectrum

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.noct_spectrum")
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.

    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: Reference gain (dB). Default is 10 dB.
        fr: Reference frequency (Hz). Default is 1000 Hz.

    Returns:
        NOctFrame containing N-octave band spectrum
    """
    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 Short-Time Fourier Transform.

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
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
@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 Short-Time Fourier Transform.

    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 magnitude squared coherence.

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
SpectralFrame

SpectralFrame containing magnitude squared coherence

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.coherence")
def coherence(
    self: TransformFrameProtocol,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
) -> "SpectralFrame":
    """Calculate magnitude squared coherence.

    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:
        SpectralFrame containing magnitude squared coherence
    """
    return self._cross_channel_spectral_transform(
        "coherence",
        "Coherence of",
        "$\\gamma_{{{in_label}, {out_label}}}$",
        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 cross-spectral density matrix.

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
SpectralFrame

SpectralFrame containing cross-spectral density matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.csd")
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",
) -> "SpectralFrame":
    """Calculate cross-spectral density matrix.

    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:
        SpectralFrame containing cross-spectral density matrix
    """
    return self._cross_channel_spectral_transform(
        "csd",
        "CSD of",
        "csd({in_label}, {out_label})",
        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 transfer function matrix.

The transfer function represents the signal transfer characteristics between channels in the frequency domain and represents the input-output relationship of the system.

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
SpectralFrame

SpectralFrame containing transfer function matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
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
@recipe_operation("wandas.audio.transfer_function")
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",
) -> "SpectralFrame":
    """Calculate transfer function matrix.

    The transfer function represents the signal transfer characteristics between
    channels in the frequency domain and represents the input-output relationship
    of the system.

    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:
        SpectralFrame containing transfer function matrix
    """
    return self._cross_channel_spectral_transform(
        "transfer_function",
        "Transfer function of",
        "$H_{{{in_label}, {out_label}}}$",
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )