Skip to content

Commit 998ac5b

Browse files
committed
Add AudioConverter to the Blocks API
Completes the audio pipeline: AudioDemuxer -> PacketDecoder -> AudioConverter, turning RawAudioSamples into normalized float32 AudioSamples, optionally resampled and remixed to another channel count. Unlike ColorConverter this block is a stream processor rather than a function of its input, because resampling is an interpolation filter: swresample holds the tail of each frame back until the next arrives. So convert() can return fewer samples than it was given, drain() is needed for the last ones, frames must be fed in order, and reset() is needed after a seek. None of that is true when sample_rate is left unset - format conversion and channel remixing are frame-local - but drain()/reset() are in the documented loop from the start so that adding sample_rate later can't silently truncate anyone's audio. We deliberately don't implement SingleStreamDecoder's pre-roll or its resampling alignment grid. The grid works by *dropping* up to isr/gcd(isr,osr)-1 input samples, which is only safe behind a pre-roll; without one it would eat the caller's own samples (up to ~1s for 44100 -> 16001). Consequence: samples decoded after a seek don't line up bit-for-bit with a whole-file decode. Decoding from the start does, which the tests pin. create_swr_context() grows an overload taking channel counts rather than an AVFrame, and the swr_convert() output-size bound moves into a shared helper.
1 parent 15311a0 commit 998ac5b

15 files changed

Lines changed: 872 additions & 57 deletions

examples/decoding/blocks.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
we illustrate a few things this enables: overlapping stages on multiple
3030
threads, accessing raw (YUV) frames, and decoding streams of unknown -
3131
possibly infinite - length.
32+
33+
Audio works the same way, through ``AudioDemuxer`` and ``AudioConverter``; we
34+
come back to it at the end.
3235
"""
3336

3437
# %%
@@ -427,3 +430,71 @@ def start_live_stream():
427430

428431
ffmpeg.kill()
429432
ffmpeg.wait()
433+
434+
# %%
435+
# Audio
436+
# -----
437+
#
438+
# Audio has the same three stages, and ``PacketDecoder`` is the same block:
439+
#
440+
# .. code-block::
441+
#
442+
# AudioDemuxer -> PacketDecoder -> AudioConverter
443+
# Packet RawAudioSamples AudioSamples
444+
#
445+
# What ``PacketDecoder`` hands out follows the demuxer it was built from, so
446+
# audio comes out as ``RawAudioSamples``: the codec's own samples, in the codec's
447+
# own sample type, as a ``[num_channels, num_samples]`` tensor.
448+
from torchcodec.decoders._blocks import AudioConverter, AudioDemuxer
449+
450+
audio_path = temp_dir / "audio.wav"
451+
subprocess.run(
452+
[
453+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
454+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=5",
455+
"-c:a", "pcm_s16le", str(audio_path),
456+
],
457+
check=True,
458+
)
459+
460+
demuxer = AudioDemuxer(audio_path)
461+
packet_decoder = PacketDecoder(demuxer)
462+
raw = next(iter(packet_decoder.decode(next(iter(demuxer)))))
463+
print(f"{raw.sample_format = }, {raw.data.dtype = }, {raw.data.shape = }, "
464+
f"{raw.sample_rate = }")
465+
466+
# %%
467+
# Those are the true source samples - 16-bit integers here, not floats in
468+
# ``[-1, 1]``. ``AudioConverter`` is what normalizes them, and it can resample
469+
# and change the channel count on the way.
470+
#
471+
# It differs from ``ColorConverter`` in one important way: resampling is an
472+
# interpolation filter, so the sample it emits at a given instant depends on
473+
# input samples on *both* sides of it. The converter therefore holds the tail
474+
# of each frame back until the next one arrives - which is why ``convert()``
475+
# can return fewer samples than it was given, and why the pipeline ends with
476+
# ``drain()``. Leave that call out and you lose the end of the stream.
477+
demuxer = AudioDemuxer(audio_path)
478+
packet_decoder = PacketDecoder(demuxer)
479+
audio_converter = AudioConverter(sample_rate=16_000, num_channels=1)
480+
481+
chunks = []
482+
for packet in demuxer:
483+
chunks += [audio_converter.convert(raw) for raw in packet_decoder.decode(packet)]
484+
chunks += [audio_converter.convert(raw) for raw in packet_decoder.drain()]
485+
chunks.append(audio_converter.drain()) # don't forget me
486+
487+
samples = torch.cat([chunk.data for chunk in chunks], dim=1)
488+
print(f"{samples.shape = }, {samples.dtype = }, "
489+
f"{chunks[-1].data.shape[1]} samples came out of drain()")
490+
491+
# %%
492+
# .. warning::
493+
#
494+
# These blocks do no pre-roll. A lossy codec's first frames after a seek are
495+
# subtly wrong until it re-primes, and a resampler started mid-stream emits
496+
# samples on a grid of its own, so samples decoded after a
497+
# ``demuxer.seek()`` do not line up bit-for-bit with the same region of a
498+
# whole-file decode. Decoding a margin before your target and discarding it
499+
# is up to you. :class:`~torchcodec.decoders.AudioDecoder` does all of this
500+
# for you.

src/torchcodec/_core/AudioCommon.cpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
#include "AudioCommon.h"
88

9+
#include <vector>
10+
911
namespace facebook::torchcodec {
1012

1113
torch::headeronly::ScalarType sample_format_dtype(
@@ -35,4 +37,65 @@ torch::headeronly::ScalarType sample_format_dtype(
3537
return kStableUInt8;
3638
}
3739

40+
AVSampleFormat planar_sample_format(torch::headeronly::ScalarType dtype) {
41+
switch (dtype) {
42+
case kStableUInt8:
43+
return AV_SAMPLE_FMT_U8P;
44+
case kStableInt16:
45+
return AV_SAMPLE_FMT_S16P;
46+
case kStableInt32:
47+
return AV_SAMPLE_FMT_S32P;
48+
case kStableInt64:
49+
return AV_SAMPLE_FMT_S64P;
50+
case kStableFloat32:
51+
return AV_SAMPLE_FMT_FLTP;
52+
case kStableFloat64:
53+
return AV_SAMPLE_FMT_DBLP;
54+
default:
55+
break;
56+
}
57+
STD_TORCH_CHECK(
58+
false,
59+
"Unsupported dtype for audio samples. Expected one of uint8, int16, "
60+
"int32, int64, float32 or float64.");
61+
return AV_SAMPLE_FMT_NONE;
62+
}
63+
64+
torch::stable::Tensor swr_convert_to_tensor(
65+
const UniqueSwrContext& swr_context,
66+
const uint8_t* const* src_planes,
67+
int num_src_samples,
68+
int num_out_channels,
69+
int64_t num_out_samples_bound) {
70+
torch::stable::Tensor out = torch::stable::empty(
71+
{num_out_channels, num_out_samples_bound}, kStableFloat32);
72+
if (num_out_samples_bound == 0) {
73+
return out;
74+
}
75+
76+
// A contiguous [num_out_channels, N] float32 tensor is exactly FLTP: one
77+
// plane per row.
78+
int64_t bytes_per_channel =
79+
num_out_samples_bound * av_get_bytes_per_sample(kAudioOutSampleFormat);
80+
auto* base = static_cast<uint8_t*>(out.mutable_data_ptr());
81+
std::vector<uint8_t*> out_planes(num_out_channels);
82+
for (int channel = 0; channel < num_out_channels; ++channel) {
83+
out_planes[channel] = base + channel * bytes_per_channel;
84+
}
85+
86+
int num_out_samples = swr_convert(
87+
swr_context.get(),
88+
out_planes.data(),
89+
static_cast<int>(num_out_samples_bound),
90+
src_planes,
91+
num_src_samples);
92+
STD_TORCH_CHECK(
93+
num_out_samples >= 0,
94+
"Error in swr_convert: ",
95+
get_ffmpeg_error_string_from_error_code(num_out_samples));
96+
97+
return torch::stable::narrow(
98+
out, /*dim=*/1, /*start=*/0, /*length=*/num_out_samples);
99+
}
100+
38101
} // namespace facebook::torchcodec

src/torchcodec/_core/AudioCommon.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,29 @@
1515

1616
namespace facebook::torchcodec {
1717

18+
// Decoded audio always leaves torchcodec as normalized float32 samples,
19+
// one plane per channel.
20+
constexpr AVSampleFormat kAudioOutSampleFormat = AV_SAMPLE_FMT_FLTP;
21+
1822
// The dtype that holds `sample_format`'s samples exactly. Planar and packed
1923
// variants of a format share a sample type, which is why this doesn't care
2024
// which one it is given.
2125
torch::headeronly::ScalarType sample_format_dtype(AVSampleFormat sample_format);
2226

27+
// The inverse: the sample format that a contiguous [num_channels, num_samples]
28+
// tensor of `dtype` already is. One row per channel is precisely what planar
29+
// means, which is what lets swresample read such a tensor's rows directly.
30+
AVSampleFormat planar_sample_format(torch::headeronly::ScalarType dtype);
31+
32+
// Runs swr_convert() straight into a fresh float32 [num_channels, N] tensor,
33+
// narrowed to the number of samples it actually produced - which is at most
34+
// `num_out_samples_bound` and is often fewer, since swresample holds back the
35+
// samples that need future input. Pass a null `src_planes` to flush those out.
36+
torch::stable::Tensor swr_convert_to_tensor(
37+
const UniqueSwrContext& swr_context,
38+
const uint8_t* const* src_planes,
39+
int num_src_samples,
40+
int num_out_channels,
41+
int64_t num_out_samples_bound);
42+
2343
} // namespace facebook::torchcodec
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) Meta Platforms, Inc. and affiliates.
2+
// All rights reserved.
3+
//
4+
// This source code is licensed under the BSD-style license found in the
5+
// LICENSE file in the root directory of this source tree.
6+
7+
#include "AudioConverter.h"
8+
9+
#include <vector>
10+
11+
#include "AudioCommon.h"
12+
13+
namespace facebook::torchcodec {
14+
15+
AudioConverter::AudioConverter(
16+
std::optional<int> sample_rate,
17+
std::optional<int> num_channels)
18+
: requested_sample_rate_(sample_rate),
19+
requested_num_channels_(num_channels) {
20+
STD_TORCH_CHECK(
21+
!sample_rate.has_value() || *sample_rate > 0,
22+
"sample_rate must be > 0. Got: ",
23+
sample_rate.value_or(0));
24+
STD_TORCH_CHECK(
25+
!num_channels.has_value() || *num_channels > 0,
26+
"num_channels must be > 0. Got: ",
27+
num_channels.value_or(0));
28+
}
29+
30+
void AudioConverter::reset() {
31+
swr_context_.reset();
32+
src_sample_format_ = AV_SAMPLE_FMT_NONE;
33+
src_sample_rate_ = 0;
34+
src_num_channels_ = 0;
35+
out_sample_rate_ = 0;
36+
out_num_channels_ = 0;
37+
}
38+
39+
torch::stable::Tensor AudioConverter::convert(
40+
const torch::stable::Tensor& samples,
41+
int sample_rate) {
42+
STD_TORCH_CHECK(
43+
samples.dim() == 2,
44+
"Expected a 2D [num_channels, num_samples] tensor, got a ",
45+
samples.dim(),
46+
"D one.");
47+
STD_TORCH_CHECK(samples.is_contiguous(), "The samples must be contiguous.");
48+
STD_TORCH_CHECK(sample_rate > 0, "sample_rate must be > 0.");
49+
50+
AVSampleFormat src_sample_format =
51+
planar_sample_format(samples.scalar_type());
52+
int num_channels = static_cast<int>(samples.sizes()[0]);
53+
int num_samples = static_cast<int>(samples.sizes()[1]);
54+
STD_TORCH_CHECK(
55+
num_channels > 0, "The samples must have at least 1 channel.");
56+
57+
if (swr_context_ == nullptr) {
58+
src_sample_format_ = src_sample_format;
59+
src_sample_rate_ = sample_rate;
60+
src_num_channels_ = num_channels;
61+
out_sample_rate_ = requested_sample_rate_.value_or(sample_rate);
62+
out_num_channels_ = requested_num_channels_.value_or(num_channels);
63+
swr_context_.reset(create_swr_context(
64+
src_sample_format_,
65+
kAudioOutSampleFormat,
66+
src_sample_rate_,
67+
out_sample_rate_,
68+
src_num_channels_,
69+
out_num_channels_));
70+
} else {
71+
// swresample is configured once, from the first samples we see, and its
72+
// buffered state is tied to that configuration. Rather than silently
73+
// reconfiguring - which would discard whatever it still holds - we make the
74+
// caller decide, by reset()ing.
75+
STD_TORCH_CHECK(
76+
src_sample_format == src_sample_format_ &&
77+
sample_rate == src_sample_rate_ &&
78+
num_channels == src_num_channels_,
79+
"This AudioConverter was set up for ",
80+
src_num_channels_,
81+
" channels of ",
82+
av_get_sample_fmt_name(src_sample_format_),
83+
" at ",
84+
src_sample_rate_,
85+
" Hz, but got ",
86+
num_channels,
87+
" channels of ",
88+
av_get_sample_fmt_name(src_sample_format),
89+
" at ",
90+
sample_rate,
91+
" Hz. Call reset() to convert a different stream.");
92+
}
93+
94+
const auto* base = static_cast<const uint8_t*>(samples.const_data_ptr());
95+
int64_t bytes_per_channel =
96+
num_samples * av_get_bytes_per_sample(src_sample_format);
97+
std::vector<const uint8_t*> src_planes(num_channels);
98+
for (int channel = 0; channel < num_channels; ++channel) {
99+
src_planes[channel] = base + channel * bytes_per_channel;
100+
}
101+
102+
return swr_convert_to_tensor(
103+
swr_context_,
104+
src_planes.data(),
105+
num_samples,
106+
out_num_channels_,
107+
get_swr_output_num_samples_bound(
108+
swr_context_, num_samples, src_sample_rate_, out_sample_rate_));
109+
}
110+
111+
torch::stable::Tensor AudioConverter::drain() {
112+
STD_TORCH_CHECK(
113+
swr_context_ != nullptr,
114+
"This AudioConverter hasn't converted any samples, so there is nothing "
115+
"to drain and no way to know what shape the result should have.");
116+
// A null input is what tells swr_convert() to flush. Unlike the convert()
117+
// path we ask swresample how much it is holding rather than deriving a bound
118+
// from an input size, since here there is no input.
119+
return swr_convert_to_tensor(
120+
swr_context_,
121+
/*src_planes=*/nullptr,
122+
/*num_src_samples=*/0,
123+
out_num_channels_,
124+
swr_get_out_samples(swr_context_.get(), 0));
125+
}
126+
127+
} // namespace facebook::torchcodec
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Meta Platforms, Inc. and affiliates.
2+
// All rights reserved.
3+
//
4+
// This source code is licensed under the BSD-style license found in the
5+
// LICENSE file in the root directory of this source tree.
6+
7+
#pragma once
8+
9+
#include <optional>
10+
11+
#include "FFMPEGCommon.h"
12+
#include "StableABICompat.h"
13+
14+
namespace facebook::torchcodec {
15+
16+
// Audio conversion building block: turns a decoded frame's samples, in the
17+
// codec's own sample type, into normalized float32 ones - optionally at another
18+
// sample rate, and with another channel count.
19+
//
20+
// Unlike ColorConverter this is a stream processor, not a function of its
21+
// input, because resampling is an interpolation filter: the sample it emits at
22+
// a given instant is a weighted sum of input samples on both sides of it. So
23+
// swresample holds the tail of each frame back until the next one arrives,
24+
// which means convert() emits fewer samples than it was given (sometimes none),
25+
// drain() is needed to get the last ones out, and frames must be fed in order.
26+
// reset() drops that state, and is what a caller must do after seeking.
27+
//
28+
// Not thread-safe.
29+
class FORCE_PUBLIC_VISIBILITY AudioConverter {
30+
public:
31+
// Both default to the source's own value, i.e. to no conversion. Note that
32+
// the sample *type* is always converted, to float32.
33+
explicit AudioConverter(
34+
std::optional<int> sample_rate = std::nullopt,
35+
std::optional<int> num_channels = std::nullopt);
36+
37+
// `samples` is a contiguous [num_channels, num_samples] tensor in the
38+
// source's own sample type, i.e. exactly what audio_samples() produces.
39+
// Returns the samples that are now computable, as float32
40+
// [out_num_channels, N] - and N may well be 0.
41+
torch::stable::Tensor convert(
42+
const torch::stable::Tensor& samples,
43+
int sample_rate);
44+
45+
// The samples swresample was still holding on to. Callers who skip this lose
46+
// the tail of the stream.
47+
torch::stable::Tensor drain();
48+
49+
// Drop the resampler's buffered state and start over, so that the next
50+
// convert() call reconfigures from the samples it is given.
51+
void reset();
52+
53+
bool has_converted_samples() const {
54+
return swr_context_ != nullptr;
55+
}
56+
57+
// Only meaningful once convert() has been called at least once.
58+
int out_sample_rate() const {
59+
return out_sample_rate_;
60+
}
61+
62+
private:
63+
std::optional<int> requested_sample_rate_;
64+
std::optional<int> requested_num_channels_;
65+
66+
UniqueSwrContext swr_context_;
67+
// All of these are set when swr_context_ is created, and describe what it was
68+
// configured for. The src_ ones are what a later convert() is checked
69+
// against: swresample is built once for one input shape.
70+
AVSampleFormat src_sample_format_ = AV_SAMPLE_FMT_NONE;
71+
int src_sample_rate_ = 0;
72+
int src_num_channels_ = 0;
73+
int out_sample_rate_ = 0;
74+
int out_num_channels_ = 0;
75+
};
76+
77+
} // namespace facebook::torchcodec

0 commit comments

Comments
 (0)