Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

### Fixed

- `extract_audio/3` and `transcode/3` re-encode a plain mono or stereo
WAV again. Before, every call that resampled or re-encoded such a file,
for example to `.mp3`, `.m4a`, or `.opus`, returned
`{:error, %Exmpeg.Error{reason: :io_error}}` with the message
``AVERROR(-1668179713): `Input changed` ``. The WAV leaves its channel
layout unspecified, and the decoder now gets the default layout for its
channel count, as in the `ffmpeg` CLI.

## 0.6.0 - 2026-09-23

### Changed
Expand Down
15 changes: 14 additions & 1 deletion native/exmpeg_native/src/audio.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! Shared helpers for the audio re-encode paths (`extract_audio`,
//! `transcode`).

use rsmpeg::avcodec::AVCodecRef;
use rsmpeg::avcodec::{AVCodecContext, AVCodecRef};
use rsmpeg::avutil::{AVChannelLayout, AVFrame};
use rsmpeg::ffi;

use crate::errors::NativeError;

Expand Down Expand Up @@ -35,6 +36,18 @@ pub(crate) fn resolve_channels(requested: Option<i32>, src: i32) -> Result<i32,
Ok(target)
}

/// Give a decoder whose channel order is unspecified (a plain WAV) the
/// default layout for its channel count, as the ffmpeg CLI does. Call it
/// between `apply_codecpar` and `open`. Otherwise `swr_init` normalises
/// its input layout to that default, and `swr_convert_frame` rejects
/// every decoded frame with `AVERROR_INPUT_CHANGED`.
pub(crate) fn default_unspecified_layout(decoder: &mut AVCodecContext) {
if decoder.ch_layout.order == ffi::AV_CHANNEL_ORDER_UNSPEC {
let layout = AVChannelLayout::from_nb_channels(decoder.ch_layout.nb_channels);
decoder.set_ch_layout(layout.into_inner());
}
}

pub(crate) fn pick_sample_fmt(codec: &AVCodecRef<'static>, src: i32) -> i32 {
if let Some(fmts) = codec.sample_fmts() {
if fmts.contains(&src) {
Expand Down
14 changes: 5 additions & 9 deletions native/exmpeg_native/src/extract_audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub(crate) fn extract_audio<Q: AsRef<Path>>(
let codecpar = input.streams()[audio_index].codecpar();
decoder.apply_codecpar(&codecpar)?;
}
crate::audio::default_unspecified_layout(&mut decoder);
decoder.set_time_base(stream_time_base);
decoder.open(None)?;

Expand Down Expand Up @@ -109,18 +110,13 @@ pub(crate) fn extract_audio<Q: AsRef<Path>>(
// The layout must match for this to be safe: a 2-channel source with
// a *specific* non-stereo mask (e.g. WAVEFORMATEXTENSIBLE FL+LFE)
// sent unremapped under the canonical target layout the output
// stream advertises would mislabel/misorder its channels. An
// *unspecified* source order with the matching channel count has no
// order to preserve, so passthrough under the canonical target is
// fine (and avoids the resampler for the common WAV case, which
// reports its layout as unspecified).
let layout_ok = ffi_helpers::channel_layouts_equal(&decoder.ch_layout, &target_layout)
|| (decoder.ch_layout.order == ffi::AV_CHANNEL_ORDER_UNSPEC
&& decoder.ch_layout.nb_channels == target_channels);
// stream advertises would mislabel/misorder its channels. A plain
// WAV with an unspecified order already has the default layout from
// `default_unspecified_layout`, so it matches here.
let passthrough = encoder.frame_size == 0
&& decoder.sample_fmt == target_fmt
&& decoder.sample_rate == target_rate
&& layout_ok;
&& ffi_helpers::channel_layouts_equal(&decoder.ch_layout, &target_layout);

let mut swr = if passthrough {
None
Expand Down
1 change: 1 addition & 0 deletions native/exmpeg_native/src/transcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ fn build_audio_pipeline(
})?;
let mut decoder = AVCodecContext::new(&decoder_codec);
decoder.apply_codecpar(codecpar)?;
crate::audio::default_unspecified_layout(&mut decoder);
// The decoder moves the timestamps of a frame it trims (Opus and AAC
// priming) only when it knows the packet time_base.
decoder.set_pkt_timebase(in_tb);
Expand Down
29 changes: 29 additions & 0 deletions test/integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,35 @@ defmodule Exmpeg.IntegrationTest do
end
end

# The audio-only fixture is a plain mono WAV, so its channel layout is
# unspecified. The next three tests re-encode it.
test "extract_audio re-encodes a mono 44.1 kHz WAV to mp3" do
wav = TestFixtures.ensure_audio_only_clip!()
out = Path.join(System.tmp_dir!(), "exmpeg_wav_mp3_#{System.unique_integer([:positive])}.mp3")
on_exit(fn -> File.rm(out) end)

assert {:ok, _stats} = Exmpeg.extract_audio(wav, out)
assert {:ok, %MediaInfo{streams: [audio]}} = Exmpeg.probe(out)
assert audio.codec in ["mp3", "mp3float"]
assert %{sample_rate: 44_100, channels: 1} = audio.audio
end

test "extract_audio resamples a mono 44.1 kHz WAV to 48 kHz opus" do
wav = TestFixtures.ensure_audio_only_clip!()
out = Path.join(System.tmp_dir!(), "exmpeg_wav_opus_#{System.unique_integer([:positive])}.opus")
on_exit(fn -> File.rm(out) end)

assert {:ok, %{sample_rate: 48_000}} = Exmpeg.extract_audio(wav, out)
end

test "transcode re-encodes a mono 44.1 kHz WAV to aac in m4a" do
wav = TestFixtures.ensure_audio_only_clip!()
out = Path.join(System.tmp_dir!(), "exmpeg_wav_aac_#{System.unique_integer([:positive])}.m4a")
on_exit(fn -> File.rm(out) end)

assert {:ok, %{streams_reencoded: 1}} = Exmpeg.transcode(wav, out, audio_codec: "aac")
end

test "concat rejects inputs with mismatched stream layouts", %{clip: clip} do
video_only = TestFixtures.ensure_video_only_clip!()
out = Path.join(System.tmp_dir!(), "exmpeg_concat_bad_#{System.unique_integer([:positive])}.mp4")
Expand Down
Loading