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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 1.17.6
- **FIX**(iOS, macOS): Fix unstable/glitchy audio on reversed clips inside multi-clip timelines. `AudioReverser` now preserves the source audio format (sample rate and channel count) instead of forcing 44.1 kHz stereo, so a reversed clip's audio matches its untouched neighbours and AVFoundation no longer has to reconcile mixed formats within a single composition audio track during looped playback. The reversed segment is also written as an AVFoundation-authored CAF/PCM file (replacing the hand-authored WAV) and inserted at its decoded duration without padding, avoiding encoder priming artifacts and audio-timing drift in the exported video.

## 1.17.5
- **FIX**(android): Fix custom audio tracks extending sped-up renders to the original source duration. Custom audio is now constrained to the rendered video timeline after global and per-clip playback speed, preventing the final video frame from freezing after the sped-up video content ends.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import AVFoundation
import Foundation

/// Pre-renders a reversed audio segment into a PCM WAV temp file.
/// Pre-renders a reversed audio segment into a CoreAudio PCM temp file.
///
/// Unlike the frame-slice approach used by the old `reverseTimeRanges`
/// implementation (which reverses ~30 chunk positions per second but plays
/// each chunk forward, causing ~30 audible artefacts per second), this
/// class decodes the audio to raw PCM, reverses every sample in-place,
/// and writes the result as a WAV file. The caller inserts the WAV into
/// the composition with a single `insertTimeRange` call — no clicks, no
/// gaps, and the audio sounds exactly like the video played backwards.
/// and writes the result as a CAF/PCM file. The caller inserts that file
/// into the composition with a single `insertTimeRange` call — no clicks,
/// no gaps, and the audio sounds exactly like the video played backwards.
internal enum AudioReverser {

/// Result of a successful reversal.
struct Result {
/// Temporary PCM WAV file. The caller MUST delete this once
/// Temporary CAF/PCM file. The caller MUST delete this once
/// the AVAssetExportSession has finished.
let outputURL: URL
/// Duration of the reversed audio.
Expand All @@ -25,7 +25,7 @@ internal enum AudioReverser {

/// Decodes [startTime, endTime) from `inputPath`, reverses the PCM
/// samples on the frame level (stereo 16-bit, 44.1 kHz), and writes
/// the result to a temporary WAV file.
/// the result to a temporary CAF/PCM file.
///
/// Returns `nil` on failure (no audio track, empty PCM, write error)
/// so the caller can fall back gracefully to no audio rather than
Expand Down Expand Up @@ -77,11 +77,30 @@ internal enum AudioReverser {
return nil
}

// Fixed decode format: 44.1 kHz stereo 16-bit LE PCM.
let sampleRate: Double = 44100
let channelCount: Int = 2
// Preserve the SOURCE audio format (sample rate + channel count) instead
// of forcing 44.1 kHz stereo. A reversed clip is dropped back into a
// multi-clip timeline next to untouched clips; if its audio track has a
// different sample rate / channel layout than its neighbours, AVFoundation
// has to reconcile mixed formats inside a single composition audio track,
// which destabilises looped playback. The reversed audio must look exactly
// like a normal clip's audio. We still decode to 16-bit LE PCM so the
// in-place frame reversal stays trivial.
let bitsPerSample: Int = 16
let bytesPerFrame = channelCount * (bitsPerSample / 8) // = 4
var sampleRate: Double = 44100
var channelCount: Int = 2
let sourceFormatDescriptions: [CMFormatDescription]
if #available(iOS 15.0, macOS 13.0, *) {
sourceFormatDescriptions = (try? await audioTrack.load(.formatDescriptions)) ?? []
} else {
sourceFormatDescriptions = audioTrack.formatDescriptions as! [CMFormatDescription]
}
if let formatDescription = sourceFormatDescriptions.first,
let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription)?.pointee
{
if asbd.mSampleRate > 0 { sampleRate = asbd.mSampleRate }
if asbd.mChannelsPerFrame > 0 { channelCount = Int(asbd.mChannelsPerFrame) }
}
let bytesPerFrame = channelCount * (bitsPerSample / 8)

let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
Expand Down Expand Up @@ -112,8 +131,8 @@ internal enum AudioReverser {
return nil
}

// Reverse PCM on the frame level (swap frame 0 ↔ last, etc.).
var bytes = [UInt8](pcm)
// Reverse PCM on the frame level (swap frame 0 ↔ last, etc.).
let frameCount = bytes.count / bytesPerFrame
var lo = 0
var hi = frameCount - 1
Expand All @@ -127,18 +146,20 @@ internal enum AudioReverser {
hi -= 1
}

// Write WAV file.
let outputURL = makeTemporaryWavURL()
// Write an AVFoundation-authored CAF/PCM file. This avoids both the
// hand-authored WAV path and AAC encoder priming in the temporary
// reversed audio asset.
let outputURL = makeTemporaryCAFURL()
do {
let wavData = makeWav(
pcmBytes: Data(bytes),
try writeCAF(
pcmBytes: bytes,
to: outputURL,
sampleRate: Int(sampleRate),
channelCount: channelCount,
bitsPerSample: bitsPerSample
)
try wavData.write(to: outputURL, options: .atomic)
} catch {
PluginLog.print("⚠️ AudioReverser: WAV write failed: \(error)")
PluginLog.print("⚠️ AudioReverser: CAF write failed: \(error)")
return nil
}

Expand All @@ -149,7 +170,7 @@ internal enum AudioReverser {
)

PluginLog.print(
"⏪ AudioReverser: reversed \(bytes.count) bytes (\(duration.seconds)s) for \(inputPath)"
"⏪ AudioReverser: reversed \(bytes.count) bytes (\(duration.seconds)s) to CAF/PCM for \(inputPath)"
)
return Result(outputURL: outputURL, duration: duration)
}
Expand Down Expand Up @@ -212,52 +233,76 @@ internal enum AudioReverser {
return pcm
}

private static func makeTemporaryWavURL() -> URL {
private static func makeTemporaryCAFURL() -> URL {
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
let name = "reverse_audio_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString).wav"
let name = "reverse_audio_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString).caf"
return tmp.appendingPathComponent(name)
}

private static func makeWav(
pcmBytes: Data,
private static func writeCAF(
pcmBytes: [UInt8],
to outputURL: URL,
sampleRate: Int,
channelCount: Int,
bitsPerSample: Int
) -> Data {
let byteRate = sampleRate * channelCount * bitsPerSample / 8
let blockAlign = channelCount * bitsPerSample / 8
let dataSize = UInt32(pcmBytes.count)
let chunkSize = UInt32(36) + dataSize
) throws {
let bytesPerFrame = channelCount * (bitsPerSample / 8)
let frameCount = pcmBytes.count / bytesPerFrame
guard frameCount > 0 else {
throw NSError(
domain: "AudioReverser",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "No PCM frames to write"]
)
}

let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: channelCount,
AVLinearPCMBitDepthKey: bitsPerSample,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false,
]

var header = Data(capacity: 44)
header.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // "RIFF"
header.appendLE(UInt32(chunkSize))
header.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // "WAVE"
header.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // "fmt "
header.appendLE(UInt32(16))
header.appendLE(UInt16(1)) // PCM
header.appendLE(UInt16(channelCount))
header.appendLE(UInt32(sampleRate))
header.appendLE(UInt32(byteRate))
header.appendLE(UInt16(blockAlign))
header.appendLE(UInt16(bitsPerSample))
header.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // "data"
header.appendLE(dataSize)
let audioFile = try AVAudioFile(
forWriting: outputURL,
settings: outputSettings,
commonFormat: .pcmFormatInt16,
interleaved: true
)

var out = Data(capacity: header.count + pcmBytes.count)
out.append(header)
out.append(pcmBytes)
return out
}
}
guard
let buffer = AVAudioPCMBuffer(
pcmFormat: audioFile.processingFormat,
frameCapacity: AVAudioFrameCount(frameCount)
)
else {
throw NSError(
domain: "AudioReverser",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "Failed to allocate PCM buffer"]
)
}

extension Data {
fileprivate mutating func appendLE(_ v: UInt32) {
var x = v.littleEndian
Swift.withUnsafeBytes(of: &x) { append(contentsOf: $0) }
}
fileprivate mutating func appendLE(_ v: UInt16) {
var x = v.littleEndian
Swift.withUnsafeBytes(of: &x) { append(contentsOf: $0) }
buffer.frameLength = AVAudioFrameCount(frameCount)
let audioBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList)
guard audioBuffers.count == 1, let destination = audioBuffers[0].mData else {
throw NSError(
domain: "AudioReverser",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "Unexpected PCM buffer layout"]
)
}

audioBuffers[0].mDataByteSize = UInt32(pcmBytes.count)
pcmBytes.withUnsafeBytes { source in
if let baseAddress = source.baseAddress {
destination.copyMemory(from: baseAddress, byteCount: pcmBytes.count)
}
}

try audioFile.write(from: buffer)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,34 +263,41 @@ internal class VideoSequenceBuilder {

do {
if clip.reverseVideo {
// True PCM-level reversal: decode → reverse samples → WAV.
// This avoids the ~30 audible artefacts/second that the
// frame-slice approach produces.
// True PCM-level reversal: decode → reverse samples → CAF/PCM.
// Keep the reversed audio at its decoded duration; padding it to
// the video duration changes the exported audio timing on iOS.
if let reversed = await AudioReverser.reverse(
inputPath: clip.inputPath,
startTime: clipTimeRange.start,
endTime: CMTimeRangeGetEnd(clipTimeRange)
) {
reversedAudioTempURLs.append(reversed.outputURL)
let wavAsset = AVURLAsset(url: reversed.outputURL)
let wavTracks: [AVAssetTrack]
let reversedAsset = AVURLAsset(url: reversed.outputURL)
let reversedTracks: [AVAssetTrack]
if #available(macOS 12.0, iOS 15.0, *) {
wavTracks = (try? await wavAsset.loadTracks(withMediaType: .audio)) ?? []
reversedTracks = (try? await reversedAsset.loadTracks(withMediaType: .audio)) ?? []
} else {
wavTracks = wavAsset.tracks(withMediaType: .audio)
reversedTracks = reversedAsset.tracks(withMediaType: .audio)
}
if let wavTrack = wavTracks.first {
// Clamp WAV duration to clipDuration to prevent audio
// overhang that would cause AVErrorInvalidVideoComposition.
let clampedDuration = CMTimeMinimum(reversed.duration, clipDuration)
let wavRange = CMTimeRange(start: .zero, duration: clampedDuration)
try sharedAudioTrack.insertTimeRange(wavRange, of: wavTrack, at: insertStart)
if let reversedTrack = reversedTracks.first {
let reversedTrackRange = reversedTrack.timeRange
let clampedDuration = CMTimeMinimum(reversedTrackRange.duration, clipDuration)
let reversedRange = CMTimeRange(
start: reversedTrackRange.start,
duration: clampedDuration
)
try sharedAudioTrack.insertTimeRange(
reversedRange,
of: reversedTrack,
at: insertStart
)
if let speed = clip.playbackSpeed, speed > 0, speed != 1.0 {
let insertedRange = CMTimeRange(start: insertStart, duration: clipDuration)
sharedAudioTrack.scaleTimeRange(insertedRange, toDuration: effectiveDuration)
}
PluginLog.print(
" ✅ Reversed audio inserted (PCM-level, \(reversed.duration.seconds)s)")
" ✅ Reversed audio inserted (CAF/PCM, \(reversed.duration.seconds)s)"
)
}
} else {
PluginLog.print(" ⚠️ AudioReverser returned nil, skipping audio for reversed clip")
Expand Down Expand Up @@ -439,7 +446,7 @@ internal struct VideoSequenceResult {
let renderSize: CGSize
let frameRate: Float
let clipInstructions: [ClipInstruction]
/// Temporary WAV files created by AudioReverser for reversed clips.
/// Temporary audio files created by AudioReverser for reversed clips.
/// Must be deleted after the export session finishes.
let reversedAudioTempURLs: [URL]
}
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: pro_video_editor
description: "A Flutter video editor: Seamlessly enhance your videos with user-friendly editing features."
version: 1.17.5
version: 1.17.6
homepage: https://github.com/hm21/pro_video_editor/
repository: https://github.com/hm21/pro_video_editor/
documentation: https://github.com/hm21/pro_video_editor/
Expand Down
Loading