Skip to content

Commit be6d503

Browse files
authored
Add AudioConverter to the Blocks API (#1667)
1 parent 78ed5ad commit be6d503

15 files changed

Lines changed: 934 additions & 61 deletions

examples/decoding/blocks.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
# LICENSE file in the root directory of this source tree.
66

77
"""
8-
===============================================
8+
========================================
99
Blocks: build your own decoding pipeline
10-
===============================================
10+
========================================
1111
1212
.. warning::
1313
@@ -22,13 +22,16 @@
2222
.. code-block::
2323
2424
VideoDemuxer -> VideoPacketDecoder -> ColorConverter
25-
Packet RawFrame RGB Frame
25+
Packet RawFrame RGB Frame
2626
2727
The blocks are passive: they never create threads, and they release the GIL.
2828
You decide how they are composed, on which threads, and where to stop. Below
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``, ``AudioPacketDecoder`` and
34+
``AudioConverter``; we come back to it at the end.
3235
"""
3336

3437
# %%
@@ -333,7 +336,7 @@ def upsample(plane):
333336

334337
# %%
335338
# Raw HDR frames
336-
# ~~~~~~~~~~~~~~
339+
# --------------
337340
#
338341
# Raw planes come at the source's own precision, so a 10-bit HDR video gives
339342
# ``uint16`` planes with all 10 bits intact - no clipping to 8 bits, and no
@@ -427,3 +430,76 @@ def start_live_stream():
427430

428431
ffmpeg.kill()
429432
ffmpeg.wait()
433+
434+
# %%
435+
# Audio
436+
# -----
437+
#
438+
# Audio has the same three stages:
439+
#
440+
# .. code-block::
441+
#
442+
# AudioDemuxer -> AudioPacketDecoder -> AudioConverter
443+
# Packet RawAudioSamples AudioSamples
444+
#
445+
# Decoding is the same operation either way, so the two packet decoders are a
446+
# single class in C++; they are separate in Python because what they hand out
447+
# isn't. Audio comes out as ``RawAudioSamples``: the codec's own samples, in
448+
# the codec's own sample type, as a ``[num_channels, num_samples]`` tensor.
449+
from torchcodec.decoders._blocks import (
450+
AudioConverter,
451+
AudioDemuxer,
452+
AudioPacketDecoder,
453+
)
454+
455+
audio_path = temp_dir / "audio.wav"
456+
subprocess.run(
457+
[
458+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
459+
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=5",
460+
"-c:a", "pcm_s16le", str(audio_path),
461+
],
462+
check=True,
463+
)
464+
465+
demuxer = AudioDemuxer(audio_path)
466+
packet_decoder = AudioPacketDecoder(demuxer)
467+
raw = next(iter(packet_decoder.decode(next(iter(demuxer)))))
468+
print(f"{raw.sample_format = }, {raw.data.dtype = }, {raw.data.shape = }, "
469+
f"{raw.sample_rate = }")
470+
471+
# %%
472+
# Those are the true source samples - 16-bit integers here, not floats in
473+
# ``[-1, 1]``. ``AudioConverter`` is what normalizes them, and it can resample
474+
# and change the channel count on the way.
475+
#
476+
# It differs from ``ColorConverter`` in one important way: resampling is an
477+
# interpolation filter, so the sample it emits at a given instant depends on
478+
# input samples on *both* sides of it. The converter therefore holds the tail
479+
# of each frame back until the next one arrives - which is why ``convert()``
480+
# can return fewer samples than it was given, and why the pipeline ends with
481+
# ``drain()``. Leave that call out and you lose the end of the stream.
482+
demuxer = AudioDemuxer(audio_path)
483+
packet_decoder = AudioPacketDecoder(demuxer)
484+
audio_converter = AudioConverter(sample_rate=16_000, num_channels=1)
485+
486+
chunks = []
487+
for packet in demuxer:
488+
chunks += [audio_converter.convert(raw) for raw in packet_decoder.decode(packet)]
489+
chunks += [audio_converter.convert(raw) for raw in packet_decoder.drain()]
490+
chunks.append(audio_converter.drain()) # don't forget me
491+
492+
samples = torch.cat([chunk.data for chunk in chunks], dim=1)
493+
print(f"{samples.shape = }, {samples.dtype = }, "
494+
f"{chunks[-1].data.shape[1]} samples came out of drain()")
495+
496+
# %%
497+
# .. warning::
498+
#
499+
# These blocks do no pre-roll. A lossy codec's first frames after a seek are
500+
# subtly wrong until it re-primes, and a resampler started mid-stream emits
501+
# samples on a grid of its own, so samples decoded after a
502+
# ``demuxer.seek()`` do not line up bit-for-bit with the same region of a
503+
# whole-file decode. Decoding a margin before your target and discarding it
504+
# is up to you. :class:`~torchcodec.decoders.AudioDecoder` does all of this
505+
# 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** 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,34 @@
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_out_channels, N]
33+
// tensor, narrowed to the number of samples it actually produced - which is
34+
// at most `num_out_samples_bound` and is often fewer, since swresample holds
35+
// back the samples that need future input. Pass a null `src_planes` to flush
36+
// those out.
37+
//
38+
// `src_planes` is deliberately `const uint8_t**` rather than
39+
// `const uint8_t* const*`: swr_convert() takes the former up to FFmpeg 6 and
40+
// the latter from 7 on, and only the former converts to both.
41+
torch::stable::Tensor swr_convert_to_tensor(
42+
const UniqueSwrContext& swr_context,
43+
const uint8_t** src_planes,
44+
int num_src_samples,
45+
int num_out_channels,
46+
int64_t num_out_samples_bound);
47+
2348
} // namespace facebook::torchcodec
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
// TODO_API_BREAKDOWN CC P2: the CPUDeviceInterface relies on
103+
// swr_convert_to_tensor but only for flushing. We effectively have two
104+
// code-paths doing libswresample conversion. Worth aligning.
105+
return swr_convert_to_tensor(
106+
swr_context_,
107+
src_planes.data(),
108+
num_samples,
109+
out_num_channels_,
110+
get_swr_output_num_samples_bound(
111+
swr_context_, num_samples, src_sample_rate_, out_sample_rate_));
112+
}
113+
114+
torch::stable::Tensor AudioConverter::drain() {
115+
STD_TORCH_CHECK(
116+
swr_context_ != nullptr,
117+
"This AudioConverter hasn't converted any samples, so there is nothing "
118+
"to drain and no way to know what shape the result should have.");
119+
// A null input is what tells swr_convert() to flush. Unlike the convert()
120+
// path we ask swresample how much it is holding rather than deriving a bound
121+
// from an input size, since here there is no input.
122+
return swr_convert_to_tensor(
123+
swr_context_,
124+
/*src_planes=*/nullptr,
125+
/*num_src_samples=*/0,
126+
out_num_channels_,
127+
swr_get_out_samples(swr_context_.get(), 0));
128+
}
129+
130+
} // namespace facebook::torchcodec

0 commit comments

Comments
 (0)