mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary - add iOS and Android voice-note recording and preview directly in the mobile composer - add waveform playback, scrubbing, speed controls, haptics, and one-shot playback in chat - package recordings in a canonical H.264/AAC MP4 envelope on both platforms so existing relays accept them - preserve the shared composer interaction and attachment-card treatment across mobile platforms Mobile counterpart to #6978. ## Testing - `just ci` - `just mobile-check` - `just mobile-test` (2,026 tests) - Android debug build compiled, installed, launched, and Voice note verified in the attachment menu on Pixel 10 - signed iOS device build installed on iPhone --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
366 lines
14 KiB
Swift
366 lines
14 KiB
Swift
import AVFoundation
|
|
import Flutter
|
|
|
|
enum VoiceNotePackager {
|
|
static let videoEnvelopeTimeout: TimeInterval = 30
|
|
static let exportTimeout: TimeInterval = 30
|
|
|
|
static func package(
|
|
sourcePath: String,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
let sourceAsset = AVURLAsset(url: URL(fileURLWithPath: sourcePath))
|
|
guard let sourceAudio = sourceAsset.tracks(withMediaType: .audio).first else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "The recording does not contain an audio track.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
let duration = sourceAudio.timeRange.duration
|
|
guard duration.isValid, duration.isNumeric, CMTimeCompare(duration, .zero) > 0 else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "The recording has no playable audio.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
Self.makeVoiceNoteVideoTrack(duration: duration) { trackResult in
|
|
switch trackResult {
|
|
case let .failure(error):
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to prepare voice note for upload.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
case let .success(videoURL):
|
|
exportVoiceNoteEnvelope(
|
|
sourceURL: URL(fileURLWithPath: sourcePath),
|
|
duration: duration,
|
|
videoURL: videoURL,
|
|
result: result
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func makeVoiceNoteVideoTrack(
|
|
duration: CMTime,
|
|
completion: @escaping (Result<URL, Error>) -> Void
|
|
) {
|
|
let outputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("mp4")
|
|
|
|
do {
|
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
|
let input = AVAssetWriterInput(
|
|
mediaType: .video,
|
|
outputSettings: [
|
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
AVVideoWidthKey: 16,
|
|
AVVideoHeightKey: 16,
|
|
AVVideoCompressionPropertiesKey: [
|
|
AVVideoAverageBitRateKey: 8000,
|
|
AVVideoExpectedSourceFrameRateKey: 1,
|
|
AVVideoMaxKeyFrameIntervalKey: 1,
|
|
],
|
|
]
|
|
)
|
|
input.expectsMediaDataInRealTime = false
|
|
guard writer.canAdd(input) else {
|
|
throw NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to create the video envelope."]
|
|
)
|
|
}
|
|
writer.add(input)
|
|
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
|
assetWriterInput: input,
|
|
sourcePixelBufferAttributes: [
|
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
|
kCVPixelBufferWidthKey as String: 16,
|
|
kCVPixelBufferHeightKey as String: 16,
|
|
]
|
|
)
|
|
guard writer.startWriting() else {
|
|
throw writer.error
|
|
?? NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to start the video envelope."]
|
|
)
|
|
}
|
|
writer.startSession(atSourceTime: .zero)
|
|
|
|
let queue = DispatchQueue(label: "xyz.block.buzz.voice-note-envelope")
|
|
var completed = false
|
|
func complete(_ trackResult: Result<URL, Error>) {
|
|
guard !completed else { return }
|
|
completed = true
|
|
if case .failure = trackResult {
|
|
writer.cancelWriting()
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
}
|
|
completion(trackResult)
|
|
}
|
|
queue.asyncAfter(deadline: .now() + videoEnvelopeTimeout) {
|
|
complete(
|
|
.failure(
|
|
NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 9,
|
|
userInfo: [NSLocalizedDescriptionKey: "Video envelope generation timed out."]
|
|
)
|
|
)
|
|
)
|
|
}
|
|
var appendedFrames = false
|
|
input.requestMediaDataWhenReady(on: queue) {
|
|
guard !completed, !appendedFrames, input.isReadyForMoreMediaData else { return }
|
|
appendedFrames = true
|
|
guard
|
|
let pool = adaptor.pixelBufferPool,
|
|
let buffer = Self.makeBlackPixelBuffer(pool: pool),
|
|
adaptor.append(buffer, withPresentationTime: .zero),
|
|
adaptor.append(buffer, withPresentationTime: duration)
|
|
else {
|
|
complete(
|
|
.failure(
|
|
writer.error
|
|
?? NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 3,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to write the video envelope."]
|
|
)
|
|
)
|
|
)
|
|
return
|
|
}
|
|
input.markAsFinished()
|
|
writer.endSession(atSourceTime: duration)
|
|
writer.finishWriting {
|
|
queue.async {
|
|
if writer.status == .completed {
|
|
complete(.success(outputURL))
|
|
} else {
|
|
complete(
|
|
.failure(
|
|
writer.error
|
|
?? NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 4,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to finish the video envelope."]
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
|
|
private static func makeBlackPixelBuffer(pool: CVPixelBufferPool) -> CVPixelBuffer? {
|
|
var pixelBuffer: CVPixelBuffer?
|
|
guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer) == kCVReturnSuccess,
|
|
let pixelBuffer
|
|
else {
|
|
return nil
|
|
}
|
|
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
|
if let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) {
|
|
memset(baseAddress, 0, CVPixelBufferGetDataSize(pixelBuffer))
|
|
}
|
|
CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
|
|
return pixelBuffer
|
|
}
|
|
|
|
private static func exportVoiceNoteEnvelope(
|
|
sourceURL: URL,
|
|
duration: CMTime,
|
|
videoURL: URL,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
// Reload the recording here so its AVAsset stays alive for the entire
|
|
// composition insert. Keeping only an AVAssetTrack across the asynchronous
|
|
// video-envelope write can leave the track detached from its source asset
|
|
// on physical devices.
|
|
let sourceAsset = AVURLAsset(url: sourceURL)
|
|
let videoAsset = AVURLAsset(url: videoURL)
|
|
let composition = AVMutableComposition()
|
|
do {
|
|
guard
|
|
let sourceAudio = sourceAsset.tracks(withMediaType: .audio).first,
|
|
let sourceVideo = videoAsset.tracks(withMediaType: .video).first,
|
|
let destinationVideo = composition.addMutableTrack(
|
|
withMediaType: .video,
|
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
),
|
|
let destinationAudio = composition.addMutableTrack(
|
|
withMediaType: .audio,
|
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
)
|
|
else {
|
|
throw NSError(
|
|
domain: "BuzzVoiceNote",
|
|
code: 5,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to assemble the voice note envelope."]
|
|
)
|
|
}
|
|
let sourceVideoRange = sourceVideo.timeRange
|
|
try destinationVideo.insertTimeRange(sourceVideoRange, of: sourceVideo, at: .zero)
|
|
destinationVideo.scaleTimeRange(
|
|
CMTimeRange(start: .zero, duration: sourceVideoRange.duration),
|
|
toDuration: duration
|
|
)
|
|
try destinationAudio.insertTimeRange(sourceAudio.timeRange, of: sourceAudio, at: .zero)
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: videoURL)
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to assemble voice note for upload.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
guard let exportSession = AVAssetExportSession(
|
|
asset: composition,
|
|
presetName: AVAssetExportPresetMediumQuality
|
|
) else {
|
|
try? FileManager.default.removeItem(at: videoURL)
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to create voice note export session.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
let outputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("mp4")
|
|
exportSession.outputURL = outputURL
|
|
exportSession.outputFileType = .mp4
|
|
exportSession.shouldOptimizeForNetworkUse = true
|
|
exportSession.metadata = []
|
|
exportSession.metadataItemFilter = nil
|
|
let completionQueue = DispatchQueue(label: "xyz.block.buzz.voice-note-export")
|
|
let completion = VoiceNoteExportCompletion(
|
|
outputURL: outputURL,
|
|
videoURL: videoURL
|
|
)
|
|
completionQueue.asyncAfter(deadline: .now() + exportTimeout) {
|
|
completion.timeout(
|
|
cancel: exportSession.cancelExport,
|
|
deliver: {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Voice note packaging timed out.",
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
exportSession.exportAsynchronously {
|
|
completionQueue.async {
|
|
completion.exportDidFinish(
|
|
succeeded: exportSession.status == .completed,
|
|
deliver: {
|
|
switch exportSession.status {
|
|
case .completed:
|
|
do {
|
|
try MP4Canonicalizer.neutralizeSampleDependencyBoxes(at: outputURL)
|
|
result(outputURL.path)
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to canonicalize voice note.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
default:
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Voice note packaging failed.",
|
|
details: exportSession.error?.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Separates one-shot Flutter result delivery from asynchronous export cleanup.
|
|
///
|
|
/// `cancelExport()` does not synchronously join AVFoundation's exporter. A late
|
|
/// terminal callback must therefore remove an output recreated after timeout,
|
|
/// even though the timeout already delivered the Flutter result.
|
|
final class VoiceNoteExportCompletion {
|
|
private let outputURL: URL
|
|
private let videoURL: URL
|
|
private let fileManager: FileManager
|
|
private var delivered = false
|
|
|
|
init(
|
|
outputURL: URL,
|
|
videoURL: URL,
|
|
fileManager: FileManager = .default
|
|
) {
|
|
self.outputURL = outputURL
|
|
self.videoURL = videoURL
|
|
self.fileManager = fileManager
|
|
}
|
|
|
|
func timeout(cancel: () -> Void, deliver: () -> Void) {
|
|
guard !delivered else { return }
|
|
delivered = true
|
|
cancel()
|
|
remove(videoURL)
|
|
remove(outputURL)
|
|
deliver()
|
|
}
|
|
|
|
func exportDidFinish(succeeded: Bool, deliver: () -> Void) {
|
|
remove(videoURL)
|
|
guard !delivered else {
|
|
remove(outputURL)
|
|
return
|
|
}
|
|
delivered = true
|
|
if !succeeded { remove(outputURL) }
|
|
deliver()
|
|
}
|
|
|
|
private func remove(_ url: URL) {
|
|
try? fileManager.removeItem(at: url)
|
|
}
|
|
}
|