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は時間領域の波形データを扱うための基本的なフレームです。

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

    def __init__(
        self,
        data: DaskArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: "FrameMetadata | dict[str, Any] | None" = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> 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.
            operation_history: History of operations applied to the frame.
            channel_metadata: Metadata for each channel.
            previous: Reference to the previous frame in the processing chain.

        Raises:
            ValueError: If data has more than 2 dimensions, or if
                sampling_rate is not positive.
        """
        # Validate sampling rate
        validate_sampling_rate(sampling_rate)

        # 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,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def _n_channels(self) -> int:
        """Returns the number of channels."""
        return int(self._data.shape[-2])

    @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_wav("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 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

    @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 operation history is recorded.

        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:
            >>> cf = ChannelFrame.read_wav("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.
        # Cast to float to avoid integer overflow when squaring (e.g. int16).
        data = self._data
        if not np.issubdtype(data.dtype, np.floating):
            data = data.astype(np.float64)
        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 operation history is recorded.

        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:
            >>> cf = ChannelFrame.read_wav("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]
        """
        # Cast to float to avoid integer overflow in abs/squaring (e.g. int16(-32768)).
        data = self._data
        if not np.issubdtype(data.dtype, np.floating):
            data = data.astype(np.float64)
        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
        --------
        >>> cf = ChannelFrame.read_wav("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:
        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)

    def add(
        self,
        other: "ChannelFrame | int | float | NDArrayReal",
        snr: float | None = None,
    ) -> "ChannelFrame":
        """Add another signal or value to the current signal.

        If SNR is specified, performs addition with consideration for
        signal-to-noise ratio.

        Args:
            other: Signal or value to add.
            snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
                other signal based on this SNR.
                self is treated as the signal, and other as the noise.

        Returns:
            A new channel frame containing the addition result (lazy execution).
        """
        logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

        if isinstance(other, ChannelFrame):
            # Check if sampling rates match
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    f"Sampling rate mismatch\n"
                    f"  Signal: {self.sampling_rate} Hz\n"
                    f"  Other: {other.sampling_rate} Hz\n"
                    f"Resample both frames to the same rate before adding."
                )

        elif isinstance(other, np.ndarray):
            other = ChannelFrame.from_numpy(other, self.sampling_rate, label="array_data")
        elif isinstance(other, int | float):
            return self + other
        else:
            raise TypeError(f"Addition target with SNR must be a ChannelFrame or NumPy array: {type(other)}")

        # If SNR is specified, adjust the length of the other signal
        if other.duration != self.duration:
            other = other.fix_length(length=self.n_samples)

        if snr is None:
            return self + other
        return self.apply_operation("add_with_snr", other=other._data, snr=snr)

    def plot(
        self,
        plot_type: str = "waveform",
        ax: Optional["Axes"] = 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:
            >>> cf = ChannelFrame.read_wav("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: Optional["Axes"] = 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 = ChannelFrame.read_wav("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 = ChannelFrame.read_wav("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
        """
        # Prepare kwargs with explicit parameters
        plot_kwargs: dict[str, Any] = {
            "fmin": fmin,
            "fmax": fmax,
            "cmap": cmap,
            "vmin": vmin,
            "vmax": vmax,
            "xlim": xlim,
            "ylim": ylim,
            "Aw": Aw,
            "waveform": waveform or {},
            "spectral": spectral or {},
        }
        # Merge with additional kwargs
        plot_kwargs.update(kwargs)

        if "axis_config" in plot_kwargs:
            logger.warning("axis_config is retained for backward compatibility but will be deprecated in the future.")
            axis_config = plot_kwargs["axis_config"]
            if "time_plot" in axis_config:
                plot_kwargs["waveform"] = axis_config["time_plot"]
            if "freq_plot" in axis_config:
                if "xlim" in axis_config["freq_plot"]:
                    vlim = axis_config["freq_plot"]["xlim"]
                    plot_kwargs["vmin"] = vlim[0]
                    plot_kwargs["vmax"] = vlim[1]
                if "ylim" in axis_config["freq_plot"]:
                    ylim_config = axis_config["freq_plot"]["ylim"]
                    plot_kwargs["ylim"] = ylim_config

        if "cbar_config" in plot_kwargs:
            logger.warning("cbar_config is retained for backward compatibility but will be deprecated in the future.")
            cbar_config = plot_kwargs["cbar_config"]
            if "vmin" in cbar_config:
                plot_kwargs["vmin"] = cbar_config["vmin"]
            if "vmax" in cbar_config:
                plot_kwargs["vmax"] = cbar_config["vmax"]

        figures: list[Figure] = []

        for ch_idx, ch in enumerate(self):
            ax: Axes
            _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
            if isinstance(_ax, Iterator):
                ax = next(iter(_ax))
            elif isinstance(_ax, Axes):
                ax = _ax
            else:
                raise TypeError(
                    f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
                )
            # Extract figure from axes (existing pattern)
            fig = getattr(ax, "figure", None)

            if fig is not None and not is_close:
                figures.append(fig)

            # Save image before closing if requested
            if image_save is not None and fig is not None:
                if self.n_channels > 1:
                    save_path = Path(image_save)
                    ch_path = save_path.parent / f"{save_path.stem}_{ch_idx}{save_path.suffix}"
                    fig.savefig(ch_path, bbox_inches="tight")
                else:
                    fig.savefig(image_save, bbox_inches="tight")

            if fig is not None:
                display(fig)
            if is_close and fig is not None:
                fig.clf()  # Clear the figure to free memory
                plt.close(fig)

            # Play audio for each channel
            display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))

        # Return figures only when is_close=False
        if is_close:
            return None
        return figures

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayReal,
        sampling_rate: float,
        label: str | None = None,
        metadata: "FrameMetadata | 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:
            if len(ch_labels) != cf.n_channels:
                raise ValueError("Number of channel labels does not match the number of channels")
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        if ch_units is not None:
            if isinstance(ch_units, str):
                ch_units = [ch_units] * cf.n_channels

            if len(ch_units) != cf.n_channels:
                raise ValueError("Number of channel units does not match the number of channels")
            for i in range(len(ch_units)):
                cf._channel_metadata[i].unit = ch_units[i]

        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: "FrameMetadata | 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.
        """
        # Redirect to from_numpy for compatibility
        # However, from_ndarray is deprecated
        logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
        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,
        normalize: bool = False,
        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 downloaded in full before processing.
                The file extension is inferred from the URL path; supply `file_type`
                explicitly when the URL has no recognisable extension.
            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.
            normalize: When False (default) and the effective file type is WAV
                (local path or URL), return raw integer PCM samples cast to float32
                (magnitudes preserved, e.g. 16384 stays 16384.0). When True,
                normalize to float32 in [-1.0, 1.0]. Non-WAV formats always use
                soundfile (normalized).
            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:
            >>> # Load WAV file (raw integer samples cast to float32 by default)
            >>> cf = ChannelFrame.from_file("audio.wav")
            >>> # Load WAV file normalized to float32 [-1.0, 1.0]
            >>> cf = ChannelFrame.from_file("audio.wav", normalize=True)
            >>> # Load specific channels
            >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
            >>> # Load CSV file
            >>> cf = ChannelFrame.from_file("data.csv", time_column=0, delimiter=",", header=0)
            >>> # Load from a URL
            >>> cf = ChannelFrame.from_file("https://example.com/audio.wav")
        """
        from .channel import ChannelFrame

        # Detect and handle URL paths — download to bytes before any path logic.
        if isinstance(path, str) and (path.startswith("http://") or path.startswith("https://")):
            import urllib.error
            import urllib.parse
            import urllib.request
            from pathlib import PurePosixPath

            _url = path  # keep original URL string for error messages
            url_path_part = urllib.parse.urlparse(_url).path
            url_ext = PurePosixPath(url_path_part).suffix.lower() or None
            if file_type is None and url_ext:
                file_type = url_ext
            try:
                with urllib.request.urlopen(_url, timeout=timeout) as _resp:
                    path = _resp.read()  # bytes — picked up by is_in_memory below
            except urllib.error.URLError as exc:
                raise OSError(
                    f"Failed to download audio from URL\n"
                    f"  URL: {_url}\n"
                    f"  Error: {exc}\n"
                    f"Verify the URL is accessible and try again."
                ) from exc
            # Preserve URL as provenance when no explicit source_name was given.
            if source_name is None:
                source_name = _url

        is_in_memory = isinstance(path, (bytes, bytearray, memoryview)) or (
            hasattr(path, "read") and not isinstance(path, (str, Path))
        )
        if is_in_memory and file_type is None:
            raise ValueError(
                "File type is required when the extension is missing\n"
                "  Cannot determine format without an extension\n"
                "  Provide file_type like '.wav' or '.csv'"
            )

        normalized_file_type = None
        if file_type is not None:
            normalized_file_type = file_type.lower()
            if not normalized_file_type.startswith("."):
                normalized_file_type = f".{normalized_file_type}"

        if is_in_memory:
            if hasattr(path, "read") and not isinstance(path, (str, Path)):
                if hasattr(path, "seek"):
                    try:
                        path.seek(0)
                    except Exception as exc:
                        # Best-effort rewind: some file-like objects are not seekable.
                        # Failure to seek is non-fatal; we still attempt to read
                        # from the current position.
                        logger.debug(
                            "Failed to rewind file-like object before read: %r",
                            exc,
                        )
                source: bytes = path.read()
            else:
                if isinstance(path, (bytes, bytearray, memoryview)):
                    source = bytes(path)
                else:
                    raise TypeError("Unexpected in-memory input type")
            path_obj: Path | None = None
            reader = get_file_reader(normalized_file_type or "", file_type=normalized_file_type)
        else:
            path_obj = Path(cast(str | Path, path))
            if not path_obj.exists():
                raise FileNotFoundError(
                    f"Audio file not found\n"
                    f"  Path: {path_obj.absolute()}\n"
                    f"  Current directory: {Path.cwd()}\n"
                    f"Please check:\n"
                    f"  - File path is correct\n"
                    f"  - File exists at the specified location\n"
                    f"  - You have read permissions for the file"
                )
            reader = get_file_reader(path_obj)

        # Build kwargs for reader
        reader_kwargs: dict[str, Any] = {}
        is_wav_file = (path_obj is not None and path_obj.suffix.lower() == ".wav") or (normalized_file_type == ".wav")
        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
        if is_wav_file:
            reader_kwargs["normalize"] = normalize

        # Get file info
        source_obj: str | Path | bytes | bytearray | memoryview | BinaryIO
        if is_in_memory:
            source_obj = source
        else:
            if path_obj is None:
                raise ValueError("Path is required when loading from file")
            source_obj = path_obj

        info = reader.get_file_info(source_obj, **reader_kwargs)
        sr = info["samplerate"]
        n_channels = info["channels"]
        n_frames = info["frames"]
        ch_labels = ch_labels or info.get("ch_labels", None)

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

        # Channel selection processing
        all_channels = list(range(n_channels))

        if channel is None:
            channels_to_load = all_channels
            logger.debug(f"Will load all channels: {channels_to_load}")
        elif isinstance(channel, int):
            if channel < 0 or channel >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
            channels_to_load = [channel]
            logger.debug(f"Will load single channel: {channel}")
        elif isinstance(channel, list | tuple):
            for ch in channel:
                if ch < 0 or ch >= n_channels:
                    raise ValueError(
                        f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                    )
            channels_to_load = list(channel)
            logger.debug(f"Will load specific channels: {channels_to_load}")
        else:
            raise TypeError("channel must be int, list, or None")

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

        # Define the loading function using the file reader
        def _load_audio() -> NDArrayReal:
            logger.debug(">>> EXECUTING DELAYED LOAD <<<")
            # 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")
            return out

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

        # Create delayed operation
        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.float32)

        # Ensure channel-wise chunks
        dask_array = dask_array.rechunk((1, -1))

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

        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 path_obj is not None:
            source_file = str(path_obj.resolve())
        elif source_name is not None:
            source_file = source_name

        cf = ChannelFrame(
            data=dask_array,
            sampling_rate=sr,
            label=frame_label,
            metadata=FrameMetadata(source_file=source_file),
        )
        if ch_labels is not None:
            if len(ch_labels) != len(cf):
                raise ValueError(
                    "Number of channel labels does not match the number of specified channels"  # noqa: E501
                )
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        return cf

    @classmethod
    def read_wav(
        cls,
        filename: str | Path | bytes | bytearray | memoryview | BinaryIO,
        labels: list[str] | None = None,
        normalize: bool = False,
    ) -> "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.
            normalize: When False (default) and the source is a WAV file path,
                return raw integer PCM samples cast to float32 (magnitudes preserved).
                For in-memory sources, always uses soundfile (normalized float32).
                When True, normalize to float32 in [-1.0, 1.0].

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

        is_in_memory = isinstance(filename, (bytes, bytearray, memoryview)) or (
            hasattr(filename, "read") and not isinstance(filename, (str, Path))
        )
        source_name: str | None = None
        if is_in_memory and hasattr(filename, "read") and not isinstance(filename, (str, Path)):
            source_name = getattr(filename, "name", None)
        cf = ChannelFrame.from_file(
            filename,
            ch_labels=labels,
            normalize=normalize,
            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 = ChannelFrame.read_csv("data.csv")
            >>> # Read CSV with custom delimiter
            >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
            >>> # Read CSV without header
            >>> cf = ChannelFrame.read_csv("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)

    def save(
        self,
        path: str | Path,
        *,
        format: str = "hdf5",
        compress: str | None = "gzip",
        overwrite: bool = False,
        dtype: str | np.dtype[Any] | None = None,
    ) -> None:
        """Save the ChannelFrame to a WDF (Wandas Data File) format.

        This saves the complete frame including all channel data and metadata
        in a format that can be loaded back with full fidelity.

        Args:
            path: Path to save the file. '.wdf' extension will be added if not present.
            format: Format to use (currently only 'hdf5' is supported)
            compress: Compression method ('gzip' by default, None for no compression)
            overwrite: Whether to overwrite existing file
            dtype: Optional data type conversion before saving (e.g. 'float32')

        Raises:
            FileExistsError: If the file exists and overwrite=False.
            NotImplementedError: For unsupported formats.

        Example:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> cf.save("audio_analysis.wdf")
        """
        from ..io.wdf_io import save as wdf_save

        wdf_save(
            self,
            path,
            format=format,
            compress=compress,
            overwrite=overwrite,
            dtype=dtype,
        )

    @classmethod
    def load(cls, path: str | Path, *, format: str = "hdf5") -> "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
            format: Format of the file (currently only 'hdf5' is supported)

        Returns:
            A new ChannelFrame with all data and metadata loaded

        Raises:
            FileNotFoundError: If the file doesn't exist
            NotImplementedError: For unsupported formats

        Example:
            >>> cf = ChannelFrame.load("audio_analysis.wdf")
        """
        from ..io.wdf_io import load as wdf_load

        return wdf_load(path, format=format)

    def add_channel(
        self,
        data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
        label: str | None = None,
        align: str = "strict",
        suffix_on_dup: str | None = None,
        inplace: bool = False,
    ) -> "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.
            inplace: If True, modifies the frame in place.
                Otherwise returns a new frame.

        Returns:
            Modified ChannelFrame (self if inplace=True, new frame otherwise).

        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 = ChannelFrame.read_wav("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 = ChannelFrame.read_wav("audio2.wav")
            >>> cf_combined = cf.add_channel(cf2)
        """
        # ndarray/dask/同型Frame対応
        if isinstance(data, ChannelFrame):
            if self.sampling_rate != data.sampling_rate:
                raise ValueError("sampling_rate不一致")
            if data.n_samples != self.n_samples:
                if align == "pad":
                    pad_len = self.n_samples - data.n_samples
                    arr = data._data
                    if pad_len > 0:
                        arr = concatenate(
                            [
                                arr,
                                _da_from_array(
                                    np.zeros(
                                        (arr.shape[0], pad_len),
                                        dtype=arr.dtype,
                                    ),
                                    chunks=(1, -1),
                                ),
                            ],
                            axis=1,
                        )
                    else:
                        arr = arr[:, : self.n_samples]
                elif align == "truncate":
                    arr = data._data[:, : self.n_samples]
                    if arr.shape[1] < self.n_samples:
                        pad_len = self.n_samples - arr.shape[1]
                        arr = concatenate(
                            [
                                arr,
                                _da_from_array(
                                    np.zeros(
                                        (arr.shape[0], pad_len),
                                        dtype=arr.dtype,
                                    ),
                                    chunks=(1, -1),
                                ),
                            ],
                            axis=1,
                        )
                else:
                    raise ValueError(
                        f"Data length mismatch\n"
                        f"  Existing frame: {self.n_samples} samples\n"
                        f"  Channel to add: {data.n_samples} samples\n"
                        f"Use align='pad' or align='truncate' to handle "
                        f"length differences."
                    )
            else:
                arr = data._data
            labels = [ch.label for ch in self._channel_metadata]
            new_labels: list[str] = []
            new_metadata_list: list[ChannelMetadata] = []
            for chmeta in data._channel_metadata:
                if label is not None:
                    new_label = f"{label}_{chmeta.label}"
                else:
                    new_label = 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)
                # Copy the entire channel_metadata and update only the label
                new_ch_meta = chmeta.model_copy(deep=True)
                new_ch_meta.label = new_label
                new_metadata_list.append(new_ch_meta)
            new_data = concatenate([self._data, arr], axis=0)

            new_chmeta = self._channel_metadata + new_metadata_list
            if inplace:
                self._data = new_data
                self._channel_metadata = new_chmeta
                return self
            else:
                return ChannelFrame(
                    data=new_data,
                    sampling_rate=self.sampling_rate,
                    label=self.label,
                    metadata=self.metadata,
                    operation_history=self.operation_history,
                    channel_metadata=new_chmeta,
                    previous=self,
                )
        if isinstance(data, np.ndarray):
            arr = _da_from_array(data.reshape(1, -1), chunks=(1, -1))
        elif isinstance(data, DaskArray):
            arr = data[None, ...] if data.ndim == 1 else data
            if arr.shape[0] != 1:
                arr = arr.reshape((1, -1))
        else:
            raise TypeError("add_channel: ndarray/dask/ChannelFrame")
        if arr.shape[1] != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - arr.shape[1]
                if pad_len > 0:
                    pad_arr = _da_from_array(
                        np.zeros((1, pad_len), dtype=arr.dtype),
                        chunks=(1, -1),
                    )
                    arr = concatenate([arr, pad_arr], axis=1)
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = arr[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    pad_arr = _da_from_array(
                        np.zeros((1, pad_len), dtype=arr.dtype),
                        chunks=(1, -1),
                    )
                    arr = concatenate([arr, pad_arr], axis=1)
            else:
                raise ValueError(
                    f"Data length mismatch\n"
                    f"  Existing frame: {self.n_samples} samples\n"
                    f"  Channel to add: {arr.shape[1]} samples\n"
                    f"Use align='pad' or align='truncate' to handle "
                    f"length differences."
                )
        labels = [ch.label for ch in self._channel_metadata]
        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_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
        if isinstance(key, int):
            if not (0 <= key < self.n_channels):
                raise IndexError(f"index {key} out of range")
            idx = key
        else:
            labels = [ch.label for ch in self._channel_metadata]
            if key not in labels:
                raise KeyError(f"label {key} not found")
            idx = labels.index(key)
        new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
        new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def rename_channels(
        self,
        mapping: dict[int | str, str],
        inplace: bool = False,
    ) -> "ChannelFrame":
        """Rename channels using a mapping dictionary.

        Args:
            mapping: Dictionary mapping old names to new names.
                Keys can be:
                - int: channel index (e.g., {0: "left"})
                - str: channel label (e.g., {"old_name": "new_name"})
            inplace: If True, modifies the frame in place.

        Returns:
            Modified ChannelFrame (self if inplace=True, new frame otherwise).

        Raises:
            KeyError: If a key in mapping doesn't exist.
            ValueError: If duplicate labels would be created.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Rename by index
            >>> cf_renamed = cf.rename_channels({0: "left", 1: "right"})
            >>> # Rename by label
            >>> cf_renamed = cf.rename_channels({"ch0": "vocals"})
        """
        labels = [ch.label for ch in self._channel_metadata]
        new_labels = labels.copy()

        # Resolve all keys to their target labels and validate
        resolved_mappings: list[tuple[int, str]] = []
        for old_key, new_label in mapping.items():
            if isinstance(old_key, int):
                # Index-based rename
                if not (0 <= old_key < self.n_channels):
                    raise KeyError(
                        f"Channel index out of range\n  Index: {old_key}\n  Total channels: {self.n_channels}"
                    )
                resolved_mappings.append((old_key, new_label))
            else:
                # Label-based rename
                if old_key not in labels:
                    raise KeyError(f"Channel label not found\n  Label: '{old_key}'\n  Existing labels: {labels}")
                idx = labels.index(old_key)
                resolved_mappings.append((idx, new_label))

        # Detect duplicate target indices in mapping
        seen_indices: dict[int, str] = {}
        for idx, new_label in resolved_mappings:
            if idx in seen_indices:
                prev_label = seen_indices[idx]
                raise ValueError(
                    "Duplicate channel rename mapping for the same index\n"
                    f"  Channel index: {idx}\n"
                    f"  Original label: '{labels[idx]}'\n"
                    f"  First new label: '{prev_label}'\n"
                    f"  Second new label: '{new_label}'\n"
                    "Provide at most one new label per channel index in mapping."
                )
            seen_indices[idx] = new_label
        # Apply mappings
        for idx, new_label in resolved_mappings:
            new_labels[idx] = new_label

        # Check for duplicate labels after all renames have been applied
        if len(set(new_labels)) != len(new_labels):
            # Identify duplicates for a more informative error
            seen: set[str] = set()
            duplicates: set[str] = set()
            for lbl in new_labels:
                if lbl in seen:
                    duplicates.add(lbl)
                else:
                    seen.add(lbl)
            raise ValueError(
                "Duplicate channel label after rename\n"
                f"  Final labels: {new_labels}\n"
                f"  Duplicates: {sorted(duplicates)}\n"
                "Ensure new channel labels are unique."
            )
        # Create updated channel_metadata list
        new_chmeta = []
        for i, ch_meta in enumerate(self._channel_metadata):
            new_ch_meta = ch_meta.model_copy(deep=True)
            new_ch_meta.label = new_labels[i]
            new_chmeta.append(new_ch_meta)

        if inplace:
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=self._data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get time index for 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_wav("audio.wav")
>>> time = signal.time
>>> print(f"Duration: {time[-1]:.3f}s")
>>> print(f"Time step: {time[1] - time[0]:.6f}s")

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 operation history is recorded.

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:

>>> cf = ChannelFrame.read_wav("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 operation history is recorded.

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:

>>> cf = ChannelFrame.read_wav("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, operation_history=None, channel_metadata=None, previous=None)

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 FrameMetadata | dict[str, Any] | None

Optional metadata dictionary.

None
operation_history list[dict[str, Any]] | None

History of operations applied to the frame.

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

Metadata for each channel.

None
previous Optional[BaseFrame[Any]]

Reference to the previous frame in the processing chain.

None

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
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
def __init__(
    self,
    data: DaskArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: "FrameMetadata | dict[str, Any] | None" = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> 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.
        operation_history: History of operations applied to the frame.
        channel_metadata: Metadata for each channel.
        previous: Reference to the previous frame in the processing chain.

    Raises:
        ValueError: If data has more than 2 dimensions, or if
            sampling_rate is not positive.
    """
    # Validate sampling rate
    validate_sampling_rate(sampling_rate)

    # 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,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )

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

cf = ChannelFrame.read_wav("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
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
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
    --------
    >>> cf = ChannelFrame.read_wav("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()

add(other, snr=None)

Add another signal or value to the current signal.

If SNR is specified, performs addition with consideration for signal-to-noise ratio.

Parameters:

Name Type Description Default
other ChannelFrame | int | float | NDArrayReal

Signal or value to add.

required
snr float | None

Signal-to-noise ratio (dB). If specified, adjusts the scale of the other signal based on this SNR. self is treated as the signal, and other as the noise.

None

Returns:

Type Description
ChannelFrame

A new channel frame containing the addition result (lazy execution).

Source code in wandas/frames/channel.py
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
def add(
    self,
    other: "ChannelFrame | int | float | NDArrayReal",
    snr: float | None = None,
) -> "ChannelFrame":
    """Add another signal or value to the current signal.

    If SNR is specified, performs addition with consideration for
    signal-to-noise ratio.

    Args:
        other: Signal or value to add.
        snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
            other signal based on this SNR.
            self is treated as the signal, and other as the noise.

    Returns:
        A new channel frame containing the addition result (lazy execution).
    """
    logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

    if isinstance(other, ChannelFrame):
        # Check if sampling rates match
        if self.sampling_rate != other.sampling_rate:
            raise ValueError(
                f"Sampling rate mismatch\n"
                f"  Signal: {self.sampling_rate} Hz\n"
                f"  Other: {other.sampling_rate} Hz\n"
                f"Resample both frames to the same rate before adding."
            )

    elif isinstance(other, np.ndarray):
        other = ChannelFrame.from_numpy(other, self.sampling_rate, label="array_data")
    elif isinstance(other, int | float):
        return self + other
    else:
        raise TypeError(f"Addition target with SNR must be a ChannelFrame or NumPy array: {type(other)}")

    # If SNR is specified, adjust the length of the other signal
    if other.duration != self.duration:
        other = other.fix_length(length=self.n_samples)

    if snr is None:
        return self + other
    return self.apply_operation("add_with_snr", other=other._data, snr=snr)

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 Optional[Axes]

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:

>>> cf = ChannelFrame.read_wav("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
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
def plot(
    self,
    plot_type: str = "waveform",
    ax: Optional["Axes"] = 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:
        >>> cf = ChannelFrame.read_wav("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 Optional[Axes]

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 = ChannelFrame.read_wav("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
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
def rms_plot(
    self,
    ax: Optional["Axes"] = 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 = ChannelFrame.read_wav("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 = ChannelFrame.read_wav("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
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
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 = ChannelFrame.read_wav("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
    """
    # Prepare kwargs with explicit parameters
    plot_kwargs: dict[str, Any] = {
        "fmin": fmin,
        "fmax": fmax,
        "cmap": cmap,
        "vmin": vmin,
        "vmax": vmax,
        "xlim": xlim,
        "ylim": ylim,
        "Aw": Aw,
        "waveform": waveform or {},
        "spectral": spectral or {},
    }
    # Merge with additional kwargs
    plot_kwargs.update(kwargs)

    if "axis_config" in plot_kwargs:
        logger.warning("axis_config is retained for backward compatibility but will be deprecated in the future.")
        axis_config = plot_kwargs["axis_config"]
        if "time_plot" in axis_config:
            plot_kwargs["waveform"] = axis_config["time_plot"]
        if "freq_plot" in axis_config:
            if "xlim" in axis_config["freq_plot"]:
                vlim = axis_config["freq_plot"]["xlim"]
                plot_kwargs["vmin"] = vlim[0]
                plot_kwargs["vmax"] = vlim[1]
            if "ylim" in axis_config["freq_plot"]:
                ylim_config = axis_config["freq_plot"]["ylim"]
                plot_kwargs["ylim"] = ylim_config

    if "cbar_config" in plot_kwargs:
        logger.warning("cbar_config is retained for backward compatibility but will be deprecated in the future.")
        cbar_config = plot_kwargs["cbar_config"]
        if "vmin" in cbar_config:
            plot_kwargs["vmin"] = cbar_config["vmin"]
        if "vmax" in cbar_config:
            plot_kwargs["vmax"] = cbar_config["vmax"]

    figures: list[Figure] = []

    for ch_idx, ch in enumerate(self):
        ax: Axes
        _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
        if isinstance(_ax, Iterator):
            ax = next(iter(_ax))
        elif isinstance(_ax, Axes):
            ax = _ax
        else:
            raise TypeError(
                f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
            )
        # Extract figure from axes (existing pattern)
        fig = getattr(ax, "figure", None)

        if fig is not None and not is_close:
            figures.append(fig)

        # Save image before closing if requested
        if image_save is not None and fig is not None:
            if self.n_channels > 1:
                save_path = Path(image_save)
                ch_path = save_path.parent / f"{save_path.stem}_{ch_idx}{save_path.suffix}"
                fig.savefig(ch_path, bbox_inches="tight")
            else:
                fig.savefig(image_save, bbox_inches="tight")

        if fig is not None:
            display(fig)
        if is_close and fig is not None:
            fig.clf()  # Clear the figure to free memory
            plt.close(fig)

        # Play audio for each channel
        display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))

    # Return figures only when is_close=False
    if is_close:
        return None
    return figures

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 FrameMetadata | 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
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
@classmethod
def from_numpy(
    cls,
    data: NDArrayReal,
    sampling_rate: float,
    label: str | None = None,
    metadata: "FrameMetadata | 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:
        if len(ch_labels) != cf.n_channels:
            raise ValueError("Number of channel labels does not match the number of channels")
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    if ch_units is not None:
        if isinstance(ch_units, str):
            ch_units = [ch_units] * cf.n_channels

        if len(ch_units) != cf.n_channels:
            raise ValueError("Number of channel units does not match the number of channels")
        for i in range(len(ch_units)):
            cf._channel_metadata[i].unit = ch_units[i]

    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 FrameMetadata | 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
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
@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: "FrameMetadata | 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.
    """
    # Redirect to from_numpy for compatibility
    # However, from_ndarray is deprecated
    logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
    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, normalize=False, 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 downloaded in full before processing. The file extension is inferred from the URL path; supply file_type explicitly when the URL has no recognisable extension.

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

When False (default) and the effective file type is WAV (local path or URL), return raw integer PCM samples cast to float32 (magnitudes preserved, e.g. 16384 stays 16384.0). When True, normalize to float32 in [-1.0, 1.0]. Non-WAV formats always use soundfile (normalized).

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

>>> # Load WAV file (raw integer samples cast to float32 by default)
>>> cf = ChannelFrame.from_file("audio.wav")
>>> # Load WAV file normalized to float32 [-1.0, 1.0]
>>> cf = ChannelFrame.from_file("audio.wav", normalize=True)
>>> # Load specific channels
>>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
>>> # Load CSV file
>>> cf = ChannelFrame.from_file("data.csv", time_column=0, delimiter=",", header=0)
>>> # Load from a URL
>>> cf = ChannelFrame.from_file("https://example.com/audio.wav")
Source code in wandas/frames/channel.py
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
@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,
    normalize: bool = False,
    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 downloaded in full before processing.
            The file extension is inferred from the URL path; supply `file_type`
            explicitly when the URL has no recognisable extension.
        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.
        normalize: When False (default) and the effective file type is WAV
            (local path or URL), return raw integer PCM samples cast to float32
            (magnitudes preserved, e.g. 16384 stays 16384.0). When True,
            normalize to float32 in [-1.0, 1.0]. Non-WAV formats always use
            soundfile (normalized).
        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:
        >>> # Load WAV file (raw integer samples cast to float32 by default)
        >>> cf = ChannelFrame.from_file("audio.wav")
        >>> # Load WAV file normalized to float32 [-1.0, 1.0]
        >>> cf = ChannelFrame.from_file("audio.wav", normalize=True)
        >>> # Load specific channels
        >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
        >>> # Load CSV file
        >>> cf = ChannelFrame.from_file("data.csv", time_column=0, delimiter=",", header=0)
        >>> # Load from a URL
        >>> cf = ChannelFrame.from_file("https://example.com/audio.wav")
    """
    from .channel import ChannelFrame

    # Detect and handle URL paths — download to bytes before any path logic.
    if isinstance(path, str) and (path.startswith("http://") or path.startswith("https://")):
        import urllib.error
        import urllib.parse
        import urllib.request
        from pathlib import PurePosixPath

        _url = path  # keep original URL string for error messages
        url_path_part = urllib.parse.urlparse(_url).path
        url_ext = PurePosixPath(url_path_part).suffix.lower() or None
        if file_type is None and url_ext:
            file_type = url_ext
        try:
            with urllib.request.urlopen(_url, timeout=timeout) as _resp:
                path = _resp.read()  # bytes — picked up by is_in_memory below
        except urllib.error.URLError as exc:
            raise OSError(
                f"Failed to download audio from URL\n"
                f"  URL: {_url}\n"
                f"  Error: {exc}\n"
                f"Verify the URL is accessible and try again."
            ) from exc
        # Preserve URL as provenance when no explicit source_name was given.
        if source_name is None:
            source_name = _url

    is_in_memory = isinstance(path, (bytes, bytearray, memoryview)) or (
        hasattr(path, "read") and not isinstance(path, (str, Path))
    )
    if is_in_memory and file_type is None:
        raise ValueError(
            "File type is required when the extension is missing\n"
            "  Cannot determine format without an extension\n"
            "  Provide file_type like '.wav' or '.csv'"
        )

    normalized_file_type = None
    if file_type is not None:
        normalized_file_type = file_type.lower()
        if not normalized_file_type.startswith("."):
            normalized_file_type = f".{normalized_file_type}"

    if is_in_memory:
        if hasattr(path, "read") and not isinstance(path, (str, Path)):
            if hasattr(path, "seek"):
                try:
                    path.seek(0)
                except Exception as exc:
                    # Best-effort rewind: some file-like objects are not seekable.
                    # Failure to seek is non-fatal; we still attempt to read
                    # from the current position.
                    logger.debug(
                        "Failed to rewind file-like object before read: %r",
                        exc,
                    )
            source: bytes = path.read()
        else:
            if isinstance(path, (bytes, bytearray, memoryview)):
                source = bytes(path)
            else:
                raise TypeError("Unexpected in-memory input type")
        path_obj: Path | None = None
        reader = get_file_reader(normalized_file_type or "", file_type=normalized_file_type)
    else:
        path_obj = Path(cast(str | Path, path))
        if not path_obj.exists():
            raise FileNotFoundError(
                f"Audio file not found\n"
                f"  Path: {path_obj.absolute()}\n"
                f"  Current directory: {Path.cwd()}\n"
                f"Please check:\n"
                f"  - File path is correct\n"
                f"  - File exists at the specified location\n"
                f"  - You have read permissions for the file"
            )
        reader = get_file_reader(path_obj)

    # Build kwargs for reader
    reader_kwargs: dict[str, Any] = {}
    is_wav_file = (path_obj is not None and path_obj.suffix.lower() == ".wav") or (normalized_file_type == ".wav")
    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
    if is_wav_file:
        reader_kwargs["normalize"] = normalize

    # Get file info
    source_obj: str | Path | bytes | bytearray | memoryview | BinaryIO
    if is_in_memory:
        source_obj = source
    else:
        if path_obj is None:
            raise ValueError("Path is required when loading from file")
        source_obj = path_obj

    info = reader.get_file_info(source_obj, **reader_kwargs)
    sr = info["samplerate"]
    n_channels = info["channels"]
    n_frames = info["frames"]
    ch_labels = ch_labels or info.get("ch_labels", None)

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

    # Channel selection processing
    all_channels = list(range(n_channels))

    if channel is None:
        channels_to_load = all_channels
        logger.debug(f"Will load all channels: {channels_to_load}")
    elif isinstance(channel, int):
        if channel < 0 or channel >= n_channels:
            raise ValueError(
                f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
            )
        channels_to_load = [channel]
        logger.debug(f"Will load single channel: {channel}")
    elif isinstance(channel, list | tuple):
        for ch in channel:
            if ch < 0 or ch >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
        channels_to_load = list(channel)
        logger.debug(f"Will load specific channels: {channels_to_load}")
    else:
        raise TypeError("channel must be int, list, or None")

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

    # Define the loading function using the file reader
    def _load_audio() -> NDArrayReal:
        logger.debug(">>> EXECUTING DELAYED LOAD <<<")
        # 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")
        return out

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

    # Create delayed operation
    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.float32)

    # Ensure channel-wise chunks
    dask_array = dask_array.rechunk((1, -1))

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

    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 path_obj is not None:
        source_file = str(path_obj.resolve())
    elif source_name is not None:
        source_file = source_name

    cf = ChannelFrame(
        data=dask_array,
        sampling_rate=sr,
        label=frame_label,
        metadata=FrameMetadata(source_file=source_file),
    )
    if ch_labels is not None:
        if len(ch_labels) != len(cf):
            raise ValueError(
                "Number of channel labels does not match the number of specified channels"  # noqa: E501
            )
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    return cf

read_wav(filename, labels=None, normalize=False) 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
normalize bool

When False (default) and the source is a WAV file path, return raw integer PCM samples cast to float32 (magnitudes preserved). For in-memory sources, always uses soundfile (normalized float32). When True, normalize to float32 in [-1.0, 1.0].

False

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Source code in wandas/frames/channel.py
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
@classmethod
def read_wav(
    cls,
    filename: str | Path | bytes | bytearray | memoryview | BinaryIO,
    labels: list[str] | None = None,
    normalize: bool = False,
) -> "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.
        normalize: When False (default) and the source is a WAV file path,
            return raw integer PCM samples cast to float32 (magnitudes preserved).
            For in-memory sources, always uses soundfile (normalized float32).
            When True, normalize to float32 in [-1.0, 1.0].

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

    is_in_memory = isinstance(filename, (bytes, bytearray, memoryview)) or (
        hasattr(filename, "read") and not isinstance(filename, (str, Path))
    )
    source_name: str | None = None
    if is_in_memory and hasattr(filename, "read") and not isinstance(filename, (str, Path)):
        source_name = getattr(filename, "name", None)
    cf = ChannelFrame.from_file(
        filename,
        ch_labels=labels,
        normalize=normalize,
        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 = ChannelFrame.read_csv("data.csv")
>>> # Read CSV with custom delimiter
>>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
>>> # Read CSV without header
>>> cf = ChannelFrame.read_csv("data.csv", header=None)
Source code in wandas/frames/channel.py
 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
@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 = ChannelFrame.read_csv("data.csv")
        >>> # Read CSV with custom delimiter
        >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
        >>> # Read CSV without header
        >>> cf = ChannelFrame.read_csv("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
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
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)

save(path, *, format='hdf5', compress='gzip', overwrite=False, dtype=None)

Save the ChannelFrame to a WDF (Wandas Data File) format.

This saves the complete frame including all channel data and metadata in a format that can be loaded back with full fidelity.

Parameters:

Name Type Description Default
path str | Path

Path to save the file. '.wdf' extension will be added if not present.

required
format str

Format to use (currently only 'hdf5' is supported)

'hdf5'
compress str | None

Compression method ('gzip' by default, None for no compression)

'gzip'
overwrite bool

Whether to overwrite existing file

False
dtype str | dtype[Any] | None

Optional data type conversion before saving (e.g. 'float32')

None

Raises:

Type Description
FileExistsError

If the file exists and overwrite=False.

NotImplementedError

For unsupported formats.

Example

cf = ChannelFrame.read_wav("audio.wav") cf.save("audio_analysis.wdf")

Source code in wandas/frames/channel.py
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
def save(
    self,
    path: str | Path,
    *,
    format: str = "hdf5",
    compress: str | None = "gzip",
    overwrite: bool = False,
    dtype: str | np.dtype[Any] | None = None,
) -> None:
    """Save the ChannelFrame to a WDF (Wandas Data File) format.

    This saves the complete frame including all channel data and metadata
    in a format that can be loaded back with full fidelity.

    Args:
        path: Path to save the file. '.wdf' extension will be added if not present.
        format: Format to use (currently only 'hdf5' is supported)
        compress: Compression method ('gzip' by default, None for no compression)
        overwrite: Whether to overwrite existing file
        dtype: Optional data type conversion before saving (e.g. 'float32')

    Raises:
        FileExistsError: If the file exists and overwrite=False.
        NotImplementedError: For unsupported formats.

    Example:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> cf.save("audio_analysis.wdf")
    """
    from ..io.wdf_io import save as wdf_save

    wdf_save(
        self,
        path,
        format=format,
        compress=compress,
        overwrite=overwrite,
        dtype=dtype,
    )

load(path, *, format='hdf5') 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
format str

Format of the file (currently only 'hdf5' is supported)

'hdf5'

Returns:

Type Description
ChannelFrame

A new ChannelFrame with all data and metadata loaded

Raises:

Type Description
FileNotFoundError

If the file doesn't exist

NotImplementedError

For unsupported formats

Example

cf = ChannelFrame.load("audio_analysis.wdf")

Source code in wandas/frames/channel.py
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
@classmethod
def load(cls, path: str | Path, *, format: str = "hdf5") -> "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
        format: Format of the file (currently only 'hdf5' is supported)

    Returns:
        A new ChannelFrame with all data and metadata loaded

    Raises:
        FileNotFoundError: If the file doesn't exist
        NotImplementedError: For unsupported formats

    Example:
        >>> cf = ChannelFrame.load("audio_analysis.wdf")
    """
    from ..io.wdf_io import load as wdf_load

    return wdf_load(path, format=format)

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

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

If True, modifies the frame in place. Otherwise returns a new frame.

False

Returns:

Type Description
ChannelFrame

Modified ChannelFrame (self if inplace=True, new frame otherwise).

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 = ChannelFrame.read_wav("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 = ChannelFrame.read_wav("audio2.wav")
>>> cf_combined = cf.add_channel(cf2)
Source code in wandas/frames/channel.py
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
def add_channel(
    self,
    data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
    label: str | None = None,
    align: str = "strict",
    suffix_on_dup: str | None = None,
    inplace: bool = False,
) -> "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.
        inplace: If True, modifies the frame in place.
            Otherwise returns a new frame.

    Returns:
        Modified ChannelFrame (self if inplace=True, new frame otherwise).

    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 = ChannelFrame.read_wav("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 = ChannelFrame.read_wav("audio2.wav")
        >>> cf_combined = cf.add_channel(cf2)
    """
    # ndarray/dask/同型Frame対応
    if isinstance(data, ChannelFrame):
        if self.sampling_rate != data.sampling_rate:
            raise ValueError("sampling_rate不一致")
        if data.n_samples != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - data.n_samples
                arr = data._data
                if pad_len > 0:
                    arr = concatenate(
                        [
                            arr,
                            _da_from_array(
                                np.zeros(
                                    (arr.shape[0], pad_len),
                                    dtype=arr.dtype,
                                ),
                                chunks=(1, -1),
                            ),
                        ],
                        axis=1,
                    )
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = data._data[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    arr = concatenate(
                        [
                            arr,
                            _da_from_array(
                                np.zeros(
                                    (arr.shape[0], pad_len),
                                    dtype=arr.dtype,
                                ),
                                chunks=(1, -1),
                            ),
                        ],
                        axis=1,
                    )
            else:
                raise ValueError(
                    f"Data length mismatch\n"
                    f"  Existing frame: {self.n_samples} samples\n"
                    f"  Channel to add: {data.n_samples} samples\n"
                    f"Use align='pad' or align='truncate' to handle "
                    f"length differences."
                )
        else:
            arr = data._data
        labels = [ch.label for ch in self._channel_metadata]
        new_labels: list[str] = []
        new_metadata_list: list[ChannelMetadata] = []
        for chmeta in data._channel_metadata:
            if label is not None:
                new_label = f"{label}_{chmeta.label}"
            else:
                new_label = 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)
            # Copy the entire channel_metadata and update only the label
            new_ch_meta = chmeta.model_copy(deep=True)
            new_ch_meta.label = new_label
            new_metadata_list.append(new_ch_meta)
        new_data = concatenate([self._data, arr], axis=0)

        new_chmeta = self._channel_metadata + new_metadata_list
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )
    if isinstance(data, np.ndarray):
        arr = _da_from_array(data.reshape(1, -1), chunks=(1, -1))
    elif isinstance(data, DaskArray):
        arr = data[None, ...] if data.ndim == 1 else data
        if arr.shape[0] != 1:
            arr = arr.reshape((1, -1))
    else:
        raise TypeError("add_channel: ndarray/dask/ChannelFrame")
    if arr.shape[1] != self.n_samples:
        if align == "pad":
            pad_len = self.n_samples - arr.shape[1]
            if pad_len > 0:
                pad_arr = _da_from_array(
                    np.zeros((1, pad_len), dtype=arr.dtype),
                    chunks=(1, -1),
                )
                arr = concatenate([arr, pad_arr], axis=1)
            else:
                arr = arr[:, : self.n_samples]
        elif align == "truncate":
            arr = arr[:, : self.n_samples]
            if arr.shape[1] < self.n_samples:
                pad_len = self.n_samples - arr.shape[1]
                pad_arr = _da_from_array(
                    np.zeros((1, pad_len), dtype=arr.dtype),
                    chunks=(1, -1),
                )
                arr = concatenate([arr, pad_arr], axis=1)
        else:
            raise ValueError(
                f"Data length mismatch\n"
                f"  Existing frame: {self.n_samples} samples\n"
                f"  Channel to add: {arr.shape[1]} samples\n"
                f"Use align='pad' or align='truncate' to handle "
                f"length differences."
            )
    labels = [ch.label for ch in self._channel_metadata]
    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_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )

remove_channel(key, inplace=False)

Source code in wandas/frames/channel.py
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
def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
    if isinstance(key, int):
        if not (0 <= key < self.n_channels):
            raise IndexError(f"index {key} out of range")
        idx = key
    else:
        labels = [ch.label for ch in self._channel_metadata]
        if key not in labels:
            raise KeyError(f"label {key} not found")
        idx = labels.index(key)
    new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
    new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )

rename_channels(mapping, inplace=False)

Rename channels using a mapping dictionary.

Parameters:

Name Type Description Default
mapping dict[int | str, str]

Dictionary mapping old names to new names. Keys can be: - int: channel index (e.g., {0: "left"}) - str: channel label (e.g., {"old_name": "new_name"})

required
inplace bool

If True, modifies the frame in place.

False

Returns:

Type Description
ChannelFrame

Modified ChannelFrame (self if inplace=True, new frame otherwise).

Raises:

Type Description
KeyError

If a key in mapping doesn't exist.

ValueError

If duplicate labels would be created.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Rename by index
>>> cf_renamed = cf.rename_channels({0: "left", 1: "right"})
>>> # Rename by label
>>> cf_renamed = cf.rename_channels({"ch0": "vocals"})
Source code in wandas/frames/channel.py
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
def rename_channels(
    self,
    mapping: dict[int | str, str],
    inplace: bool = False,
) -> "ChannelFrame":
    """Rename channels using a mapping dictionary.

    Args:
        mapping: Dictionary mapping old names to new names.
            Keys can be:
            - int: channel index (e.g., {0: "left"})
            - str: channel label (e.g., {"old_name": "new_name"})
        inplace: If True, modifies the frame in place.

    Returns:
        Modified ChannelFrame (self if inplace=True, new frame otherwise).

    Raises:
        KeyError: If a key in mapping doesn't exist.
        ValueError: If duplicate labels would be created.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Rename by index
        >>> cf_renamed = cf.rename_channels({0: "left", 1: "right"})
        >>> # Rename by label
        >>> cf_renamed = cf.rename_channels({"ch0": "vocals"})
    """
    labels = [ch.label for ch in self._channel_metadata]
    new_labels = labels.copy()

    # Resolve all keys to their target labels and validate
    resolved_mappings: list[tuple[int, str]] = []
    for old_key, new_label in mapping.items():
        if isinstance(old_key, int):
            # Index-based rename
            if not (0 <= old_key < self.n_channels):
                raise KeyError(
                    f"Channel index out of range\n  Index: {old_key}\n  Total channels: {self.n_channels}"
                )
            resolved_mappings.append((old_key, new_label))
        else:
            # Label-based rename
            if old_key not in labels:
                raise KeyError(f"Channel label not found\n  Label: '{old_key}'\n  Existing labels: {labels}")
            idx = labels.index(old_key)
            resolved_mappings.append((idx, new_label))

    # Detect duplicate target indices in mapping
    seen_indices: dict[int, str] = {}
    for idx, new_label in resolved_mappings:
        if idx in seen_indices:
            prev_label = seen_indices[idx]
            raise ValueError(
                "Duplicate channel rename mapping for the same index\n"
                f"  Channel index: {idx}\n"
                f"  Original label: '{labels[idx]}'\n"
                f"  First new label: '{prev_label}'\n"
                f"  Second new label: '{new_label}'\n"
                "Provide at most one new label per channel index in mapping."
            )
        seen_indices[idx] = new_label
    # Apply mappings
    for idx, new_label in resolved_mappings:
        new_labels[idx] = new_label

    # Check for duplicate labels after all renames have been applied
    if len(set(new_labels)) != len(new_labels):
        # Identify duplicates for a more informative error
        seen: set[str] = set()
        duplicates: set[str] = set()
        for lbl in new_labels:
            if lbl in seen:
                duplicates.add(lbl)
            else:
                seen.add(lbl)
        raise ValueError(
            "Duplicate channel label after rename\n"
            f"  Final labels: {new_labels}\n"
            f"  Duplicates: {sorted(duplicates)}\n"
            "Ensure new channel labels are unique."
        )
    # Create updated channel_metadata list
    new_chmeta = []
    for i, ch_meta in enumerate(self._channel_metadata):
        new_ch_meta = ch_meta.model_copy(deep=True)
        new_ch_meta.label = new_labels[i]
        new_chmeta.append(new_ch_meta)

    if inplace:
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=self._data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )

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.

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 The FFT size used to generate this spectral data. 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. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

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 the processing history and metadata through all operations.
Source code in wandas/frames/spectral.py
 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
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
        The FFT size used to generate this spectral data.
    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.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    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 the processing history and metadata through all operations.
    """

    n_fft: int
    window: str

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        window: str = "hann",
        label: str | None = None,
        metadata: FrameMetadata | dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: BaseFrame[Any] | None = None,
    ) -> None:
        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}")
        self.n_fft = n_fft
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            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 _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels.
        """
        return int(self._data.shape[-2])

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

        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

    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
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"ifft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

    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_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

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

    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, which is required for this operation.
        """
        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")

        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, **params},
            operation_history=[
                *self.operation_history,
                {
                    "operation": "noct_synthesis",
                    "params": params,
                },
            ],
            channel_metadata=self._channel_metadata,
            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.

Returns

NDArrayReal Array of frequency values corresponding to each frequency bin.

Functions

__init__(data, sampling_rate, n_fft, window='hann', label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Source code in wandas/frames/spectral.py
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
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    window: str = "hann",
    label: str | None = None,
    metadata: FrameMetadata | dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: BaseFrame[Any] | None = None,
) -> None:
    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}")
    self.n_fft = n_fft
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        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
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
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
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
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
    return ChannelFrame(
        data=time_series,
        sampling_rate=self.sampling_rate,
        label=f"ifft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )

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, which is required for this operation.

Source code in wandas/frames/spectral.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
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
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, which is required for this operation.
    """
    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")

    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, **params},
        operation_history=[
            *self.operation_history,
            {
                "operation": "noct_synthesis",
                "params": params,
            },
        ],
        channel_metadata=self._channel_metadata,
        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
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
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
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
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()

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. 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. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

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
 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
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
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.
    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.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

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

    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: "FrameMetadata | dict[str, Any] | None" = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        if data.ndim == 2:
            data = da.expand_dims(data, axis=0)  # type: ignore [unused-ignore]
        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 not data.shape[-2] == n_fft // 2 + 1:
            raise ValueError(
                f"Invalid frequency bin count\n"
                f"  Got: {data.shape[-2]} bins\n"
                f"  Expected: {n_fft // 2 + 1} bins (n_fft={n_fft})\n"
                f"Ensure data shape matches the specified n_fft parameter."
            )

        self.n_fft = n_fft
        self.hop_length = hop_length
        self.win_length = win_length if win_length is not None else n_fft
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels.
        """
        return int(self._data.shape[-3])

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

        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.

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

    def plot(
        self,
        plot_type: str = "spectrogram",
        ax: Optional["Axes"] = 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
        **kwargs: Any,
    ) -> Union["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.
        **kwargs : dict
            Additional keyword arguments passed to librosa.display.specshow().

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

        # プロット戦略を取得
        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

        # プロット実行
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def plot_Aw(  # noqa: N802
        self,
        plot_type: str = "spectrogram",
        ax: Optional["Axes"] = None,
        **kwargs: Any,
    ) -> Union["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)

    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 is stored in a new SpectrogramFrame with complex dtype to maintain
        compatibility with other spectrogram operations.

        Returns
        -------
        SpectrogramFrame
            A new SpectrogramFrame containing the magnitude values as complex numbers
            (with zero imaginary parts).

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

        # Compute the absolute value using dask for lazy evaluation
        magnitude_data = da.absolute(self._data)

        # Update operation history
        operation_metadata = {"operation": "abs", "params": {}}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata["abs"] = {}

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

        return SpectrogramFrame(
            data=magnitude_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"abs({self.label})",
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=self._channel_metadata,
            previous=self,
        )

    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]

        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,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

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

        # 操作インスタンスを作成
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("ISTFT", operation)
        # データに処理を適用
        time_series = operation.process(self._data)

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

        # 新しいインスタンスを作成
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"istft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

    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_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    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,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = 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.
            operation_history: History of operations applied to the 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 == 1:
            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 >= 4:
            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 mypy 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,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )
        return sf

Attributes

n_fft = n_fft instance-attribute

hop_length = hop_length instance-attribute

win_length = win_length if win_length is not None else n_fft 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.

Returns

NDArrayReal Array of frequency values corresponding to each frequency bin.

times property

Get the time axis values in seconds.

Returns

NDArrayReal Array of time values corresponding to each time frame.

Functions

__init__(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Source code in wandas/frames/spectrogram.py
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
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: "FrameMetadata | dict[str, Any] | None" = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    if data.ndim == 2:
        data = da.expand_dims(data, axis=0)  # type: ignore [unused-ignore]
    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 not data.shape[-2] == n_fft // 2 + 1:
        raise ValueError(
            f"Invalid frequency bin count\n"
            f"  Got: {data.shape[-2]} bins\n"
            f"  Expected: {n_fft // 2 + 1} bins (n_fft={n_fft})\n"
            f"Ensure data shape matches the specified n_fft parameter."
        )

    self.n_fft = n_fft
    self.hop_length = hop_length
    self.win_length = win_length if win_length is not None else n_fft
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        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, **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. **kwargs : dict Additional keyword arguments passed to librosa.display.specshow().

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
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
def plot(
    self,
    plot_type: str = "spectrogram",
    ax: Optional["Axes"] = 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
    **kwargs: Any,
) -> Union["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.
    **kwargs : dict
        Additional keyword arguments passed to librosa.display.specshow().

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

    # プロット戦略を取得
    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

    # プロット実行
    _ax = plot_strategy.plot(self, ax=ax, **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
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
def plot_Aw(  # noqa: N802
    self,
    plot_type: str = "spectrogram",
    ax: Optional["Axes"] = None,
    **kwargs: Any,
) -> Union["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)

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 is stored in a new SpectrogramFrame with complex dtype to maintain compatibility with other spectrogram operations.

Returns

SpectrogramFrame A new SpectrogramFrame containing the magnitude values as complex numbers (with zero imaginary parts).

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
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
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 is stored in a new SpectrogramFrame with complex dtype to maintain
    compatibility with other spectrogram operations.

    Returns
    -------
    SpectrogramFrame
        A new SpectrogramFrame containing the magnitude values as complex numbers
        (with zero imaginary parts).

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

    # Compute the absolute value using dask for lazy evaluation
    magnitude_data = da.absolute(self._data)

    # Update operation history
    operation_metadata = {"operation": "abs", "params": {}}
    new_history = self.operation_history.copy()
    new_history.append(operation_metadata)
    new_metadata = {**self.metadata}
    new_metadata["abs"] = {}

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

    return SpectrogramFrame(
        data=magnitude_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"abs({self.label})",
        metadata=new_metadata,
        operation_history=new_history,
        channel_metadata=self._channel_metadata,
        previous=self,
    )

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

    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,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )

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

    # 操作インスタンスを作成
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("ISTFT", operation)
    # データに処理を適用
    time_series = operation.process(self._data)

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

    # 新しいインスタンスを作成
    return ChannelFrame(
        data=time_series,
        sampling_rate=self.sampling_rate,
        label=f"istft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )

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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
def 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
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
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
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
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, operation_history=None, channel_metadata=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
operation_history list[dict[str, Any]] | None

History of operations applied to the frame.

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

Metadata for each channel.

None
previous Optional[BaseFrame[Any]]

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
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
@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,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = 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.
        operation_history: History of operations applied to the 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 == 1:
        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 >= 4:
        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 mypy 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,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        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. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

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.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

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

    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,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> 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.
        """
        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,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            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).
        """
        # Collect dB reference values from _channel_metadata
        ref = np.array([ch.ref for ch in self._channel_metadata])
        # Convert to dB
        # Use either the maximum value or 1e-12 to avoid division by zero
        level: NDArrayReal = 20 * np.log10(np.maximum(self.data / ref[..., np.newaxis], 1e-12))
        return level

    @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 = librosa.A_weighting(frequencies=self.freqs, min_db=None)
        return self.dB + weighted

    @property
    def _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels in the N-octave band data.
        """
        return int(self._data.shape[-2])

    @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
        else:
            raise ValueError("freqs is not numpy array.")

    def _binary_op(
        self: S,
        other: S | int | float | 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: Optional["Axes"] = 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,
    ) -> Union["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_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get frequency index for 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, operation_history=None, channel_metadata=None, previous=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.

Source code in wandas/frames/noct.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    fmin: float = 0,
    fmax: float = 0,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> 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.
    """
    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,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        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
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
def plot(
    self,
    plot_type: str = "noct",
    ax: Optional["Axes"] = 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,
) -> Union["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

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

    # Overload 1: no domain transition — return type matches caller's frame type.
    @overload
    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 = ...,
        **kwargs: Any,
    ) -> T_Processing: ...

    # Overload 2: domain transition — output_frame_class determines the return
    # type statically via T_OutputFrame.
    @overload
    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 = ...,
        **kwargs: Any,
    ) -> T_OutputFrame: ...

    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,
        **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}``).
            **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())}"
            )

        operation = CustomOperation(
            sampling_rate=self.sampling_rate,
            func=func,
            output_shape_func=output_shape_func,
            **kwargs,
        )

        return cast(Any, self)._apply_operation_instance(
            operation,
            output_frame_class=output_frame_class,
            output_frame_kwargs=output_frame_kwargs,
        )

    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_operation("highpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    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_operation("lowpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    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_operation(
            "bandpass_filter",
            low_cutoff=low_cutoff,
            high_cutoff=high_cutoff,
            order=order,
        )
        return cast(T_Processing, result)

    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 librosa.util.normalize.

        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_wav("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_operation("normalize", norm=norm, axis=axis, threshold=threshold, fill=fill)
        return cast(T_Processing, result)

    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_wav("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_operation("remove_dc")
        return cast(T_Processing, result)

    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_operation("a_weighting")
        return cast(T_Processing, result)

    def abs(self: T_Processing) -> T_Processing:
        """Compute the absolute value of the signal.

        Returns:
            New ChannelFrame containing the absolute values
        """
        result = self.apply_operation("abs")
        return cast(T_Processing, result)

    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_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')."""
        if op == "sum":
            reduced_data = self._data.sum(axis=0, keepdims=True)
            label = "sum"
        elif op == "mean":
            reduced_data = self._data.mean(axis=0, keepdims=True)
            label = "mean"
        else:
            raise ValueError(f"Unsupported reduction operation: {op}")

        units = [ch.unit for ch in self._channel_metadata]
        if all(u == units[0] for u in units):
            reduced_unit = units[0]
        else:
            reduced_unit = ""

        reduced_extra = {"source_extras": [ch.extra for ch in self._channel_metadata]}
        new_channel_metadata = [
            ChannelMetadata(
                label=label,
                unit=reduced_unit,
                extra=reduced_extra,
            )
        ]
        new_history = self.operation_history.copy() if hasattr(self, "operation_history") else []
        new_history.append({"operation": op})
        new_metadata = self.metadata.copy() if hasattr(self, "metadata") else {}
        result = self._create_new_instance(
            data=reduced_data,
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=new_channel_metadata,
        )
        return result

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

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

    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_operation("trim", start=start, end=end)
        return cast(T_Processing, result)

    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_operation("fix_length", length=length, duration=duration)
        return cast(T_Processing, result)

    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
        frame = cast(ProcessingFrameProtocol, self)

        # Ensure _channel_metadata exists before referencing
        ref_values = []
        if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
            ref_values = [ch.ref for ch in frame._channel_metadata]

        result = self.apply_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)

    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.
        """
        frame = cast(ProcessingFrameProtocol, self)

        ref_values: list[float] = []
        if (
            hasattr(frame, "_channel_metadata")
            and frame._channel_metadata
            and any(ch.unit or ch.ref != 1.0 for ch in frame._channel_metadata)
        ):
            ref_values = [ch.ref for ch in frame._channel_metadata]

        result = self.apply_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)

    def channel_difference(self: T_Processing, other_channel: int | str = 0) -> T_Processing:
        """Compute the difference between channels.

        Args:
            other_channel: Index or label of the reference channel. Default is 0.

        Returns:
            New ChannelFrame containing the channel difference
        """
        # label2index is a method of BaseFrame
        if isinstance(other_channel, str):
            if hasattr(self, "label2index"):
                other_channel = self.label2index(other_channel)

        result = self.apply_operation("channel_difference", other_channel=other_channel)
        return cast(T_Processing, result)

    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_operation(
                "resampling",
                target_sr=target_sr,
                **kwargs,
            ),
        )

    def hpss_harmonic(
        self: T_Processing,
        kernel_size: Union["_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "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.
        """
        result = self.apply_operation(
            "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,
        )
        return cast(T_Processing, result)

    def hpss_percussive(
        self: T_Processing,
        kernel_size: Union["_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "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.
        """
        result = self.apply_operation(
            "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,
        )
        return cast(T_Processing, result)

    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_wav("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_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_wav("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"
        """
        # Treat self as a ProcessingFrameProtocol so mypy understands
        # where sampling_rate and data come from.
        from wandas.processing.psychoacoustic import LoudnessZwst
        from wandas.utils.types import NDArrayReal

        # Create operation instance
        operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

        # Get data (triggers computation if lazy)
        data = self.data

        # Ensure data is 2D (n_channels, n_samples)
        if data.ndim == 1:
            data = data.reshape(1, -1)
        # Process the array using the public API and materialize to NumPy
        result = operation.process_array(data).compute()

        # Squeeze to get 1D array (n_channels,)
        loudness_values: NDArrayReal = result.squeeze()

        # Ensure it's 1D even for single channel
        if loudness_values.ndim == 0:
            loudness_values = loudness_values.reshape(1)

        return loudness_values

    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_wav("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_wav("motor_before.wav").roughness_dw()
            >>> after = wd.read_wav("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_operation("roughness_dw", overlap=overlap)
        return cast(T_Processing, result)

    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_wav("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 self._data (Dask)
        r_spec_dask = operation.process(self._data)

        # Get metadata updates (sampling rate, bark_axis)
        metadata_updates = operation.get_metadata_updates()

        # Build metadata and history
        new_metadata = {**self.metadata, **params}
        new_history = [
            *self.operation_history,
            {"operation": operation_name, "params": 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
        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,
            operation_history=new_history,
            channel_metadata=self._channel_metadata,
            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

    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_wav("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_operation("fade", fade_ms=fade_ms)
        return cast(T_Processing, result)

    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_wav("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_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_wav("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
        from wandas.utils.types import NDArrayReal

        # Create operation instance
        operation = SharpnessDinSt(self.sampling_rate, weighting=weighting, field_type=field_type)

        # Get data (triggers computation if lazy)
        data = self.data

        # Ensure data is 2D (n_channels, n_samples)
        if data.ndim == 1:
            data = data.reshape(1, -1)
        # Process the array using the public API and materialize to NumPy
        result = operation.process_array(data).compute()

        # Squeeze to get 1D array (n_channels,)
        sharpness_values: NDArrayReal = result.squeeze()

        # Ensure it's 1D even for single channel
        if sharpness_values.ndim == 0:
            sharpness_values = sharpness_values.reshape(1)

        return sharpness_values

Functions

apply(func, output_shape_func=None, output_frame_class=None, output_frame_kwargs=None, **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 = ..., **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 = ..., **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
**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
 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
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,
    **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}``).
        **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())}"
        )

    operation = CustomOperation(
        sampling_rate=self.sampling_rate,
        func=func,
        output_shape_func=output_shape_func,
        **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
108
109
110
111
112
113
114
115
116
117
118
119
120
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_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
122
123
124
125
126
127
128
129
130
131
132
133
134
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_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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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_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 librosa.util.normalize.

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_wav("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
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
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 librosa.util.normalize.

    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_wav("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_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_wav("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
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
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_wav("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_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
233
234
235
236
237
238
239
240
241
242
243
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_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
245
246
247
248
249
250
251
252
def abs(self: T_Processing) -> T_Processing:
    """Compute the absolute value of the signal.

    Returns:
        New ChannelFrame containing the absolute values
    """
    result = self.apply_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
254
255
256
257
258
259
260
261
262
263
264
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_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
302
303
304
305
306
307
308
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
310
311
312
313
314
315
316
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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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_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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
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_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
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
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
    frame = cast(ProcessingFrameProtocol, self)

    # Ensure _channel_metadata exists before referencing
    ref_values = []
    if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
        ref_values = [ch.ref for ch in frame._channel_metadata]

    result = self.apply_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
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
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.
    """
    frame = cast(ProcessingFrameProtocol, self)

    ref_values: list[float] = []
    if (
        hasattr(frame, "_channel_metadata")
        and frame._channel_metadata
        and any(ch.unit or ch.ref != 1.0 for ch in frame._channel_metadata)
    ):
        ref_values = [ch.ref for ch in frame._channel_metadata]

    result = self.apply_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 the difference between channels.

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

Source code in wandas/frames/mixins/channel_processing_mixin.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def channel_difference(self: T_Processing, other_channel: int | str = 0) -> T_Processing:
    """Compute the difference between channels.

    Args:
        other_channel: Index or label of the reference channel. Default is 0.

    Returns:
        New ChannelFrame containing the channel difference
    """
    # label2index is a method of BaseFrame
    if isinstance(other_channel, str):
        if hasattr(self, "label2index"):
            other_channel = self.label2index(other_channel)

    result = self.apply_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
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
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_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 Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

Margin size for the separation.

1
n_fft int

Size of FFT window.

2048
hop_length int | None

Hop length for STFT.

None
win_length int | None

Window length for STFT.

None
window _WindowSpec

Window type for STFT.

'hann'
center bool

If True, center the frames.

True
pad_mode _PadModeSTFT

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
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
def hpss_harmonic(
    self: T_Processing,
    kernel_size: Union["_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "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.
    """
    result = self.apply_operation(
        "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,
    )
    return cast(T_Processing, result)

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 Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

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
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
def hpss_percussive(
    self: T_Processing,
    kernel_size: Union["_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "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.
    """
    result = self.apply_operation(
        "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,
    )
    return cast(T_Processing, result)

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_wav("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
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
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_wav("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_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_wav("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
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
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_wav("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"
    """
    # Treat self as a ProcessingFrameProtocol so mypy understands
    # where sampling_rate and data come from.
    from wandas.processing.psychoacoustic import LoudnessZwst
    from wandas.utils.types import NDArrayReal

    # Create operation instance
    operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

    # Get data (triggers computation if lazy)
    data = self.data

    # Ensure data is 2D (n_channels, n_samples)
    if data.ndim == 1:
        data = data.reshape(1, -1)
    # Process the array using the public API and materialize to NumPy
    result = operation.process_array(data).compute()

    # Squeeze to get 1D array (n_channels,)
    loudness_values: NDArrayReal = result.squeeze()

    # Ensure it's 1D even for single channel
    if loudness_values.ndim == 0:
        loudness_values = loudness_values.reshape(1)

    return loudness_values

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_wav("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_wav("motor_before.wav").roughness_dw()
>>> after = wd.read_wav("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
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
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_wav("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_wav("motor_before.wav").roughness_dw()
        >>> after = wd.read_wav("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_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_wav("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
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
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_wav("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 self._data (Dask)
    r_spec_dask = operation.process(self._data)

    # Get metadata updates (sampling rate, bark_axis)
    metadata_updates = operation.get_metadata_updates()

    # Build metadata and history
    new_metadata = {**self.metadata, **params}
    new_history = [
        *self.operation_history,
        {"operation": operation_name, "params": 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
    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,
        operation_history=new_history,
        channel_metadata=self._channel_metadata,
        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_wav("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
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
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_wav("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_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_wav("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
 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
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_wav("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_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_wav("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
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
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_wav("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
    from wandas.utils.types import NDArrayReal

    # Create operation instance
    operation = SharpnessDinSt(self.sampling_rate, weighting=weighting, field_type=field_type)

    # Get data (triggers computation if lazy)
    data = self.data

    # Ensure data is 2D (n_channels, n_samples)
    if data.ndim == 1:
        data = data.reshape(1, -1)
    # Process the array using the public API and materialize to NumPy
    result = operation.process_array(data).compute()

    # Squeeze to get 1D array (n_channels,)
    sharpness_values: NDArrayReal = result.squeeze()

    # Ensure it's 1D even for single channel
    if sharpness_values.ndim == 0:
        sharpness_values = sharpness_values.reshape(1)

    return sharpness_values

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
 19
 20
 21
 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
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
class 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.
    """

    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

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

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

        if n_fft is None:
            is_even = spectrum_data.shape[-1] % 2 == 0
            _n_fft = spectrum_data.shape[-1] * 2 - 2 if is_even else spectrum_data.shape[-1] * 2 - 1
        else:
            _n_fft = n_fft

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        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, "window": window, "n_fft": _n_fft},
            operation_history=[
                *self.operation_history,
                {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    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 = dict(
            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._data)

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

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        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, **params},
            operation_history=[
                *self.operation_history,
                {"operation": "welch", "params": params},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

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

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

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        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, **params},
            operation_history=[
                *self.operation_history,
                {
                    "operation": "noct_spectrum",
                    "params": params,
                },
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

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

        logger.debug(
            f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new instance
        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,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    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
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.processing import Coherence, create_operation

        from ..spectral import SpectralFrame

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
        }
        operation_name = "coherence"
        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("Coherence", operation)

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

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

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=coherence_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Coherence of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    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
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import CSD, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "csd"
        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("CSD", operation)

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

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

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=csd_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    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
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import TransferFunction, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "transfer_function"
        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("TransferFunction", operation)

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

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

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=tf_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Transfer function of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

Functions

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

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

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

    if n_fft is None:
        is_even = spectrum_data.shape[-1] % 2 == 0
        _n_fft = spectrum_data.shape[-1] * 2 - 2 if is_even else spectrum_data.shape[-1] * 2 - 1
    else:
        _n_fft = n_fft

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    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, "window": window, "n_fft": _n_fft},
        operation_history=[
            *self.operation_history,
            {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )

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
 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
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 = dict(
        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._data)

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

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    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, **params},
        operation_history=[
            *self.operation_history,
            {"operation": "welch", "params": params},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )

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

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

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    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, **params},
        operation_history=[
            *self.operation_history,
            {
                "operation": "noct_spectrum",
                "params": params,
            },
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )

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

    logger.debug(
        f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new instance
    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,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )

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
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
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
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.processing import Coherence, create_operation

    from ..spectral import SpectralFrame

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
    }
    operation_name = "coherence"
    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("Coherence", operation)

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

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

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=coherence_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Coherence of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )

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
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
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
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import CSD, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "csd"
    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("CSD", operation)

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

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

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=csd_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )

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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
def 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
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import TransferFunction, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "transfer_function"
    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("TransferFunction", operation)

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

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

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(in_ch=in_ch["metadata"], out_ch=out_ch["metadata"])
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=tf_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Transfer function of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )