From 4b509a20665382eb3c3b3301a701e07deb95d5df Mon Sep 17 00:00:00 2001 From: Chaus Date: Sun, 31 May 2026 14:50:10 +0200 Subject: [PATCH 1/3] Add live audio waveform during voice message recording --- Signal.xcodeproj/project.pbxproj | 4 + .../CellViews/LiveAudioWaveformView.swift | 87 +++++++++++++++++++ .../ConversationInputToolbar.swift | 20 +++++ ...ler+ConversationInputToolbarDelegate.swift | 7 ++ .../VoiceMessageInProgressDraft.swift | 7 ++ 5 files changed, 125 insertions(+) create mode 100644 Signal/ConversationView/CellViews/LiveAudioWaveformView.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 41d570d4604..ccd96432927 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1741,6 +1741,7 @@ A1A018521805C5E800A052A6 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A1A018531805C60D00A052A6 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A091169C9E5E00537ABF /* CoreGraphics.framework */; }; A5E7C675248C5443007C949A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A5E7C673248C5442007C949A /* InfoPlist.strings */; }; + A68C407AAE86CE67FAC11F79 /* LiveAudioWaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */; }; B60EDE041A05A01700D73516 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B60EDE031A05A01700D73516 /* AudioToolbox.framework */; }; B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; B69CD25119773E79005CE69A /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B69CD25019773E79005CE69A /* XCTest.framework */; }; @@ -4230,6 +4231,7 @@ 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = ""; }; 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = ""; }; 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = ""; }; + 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveAudioWaveformView.swift; path = Signal/ConversationView/CellViews/LiveAudioWaveformView.swift; sourceTree = SOURCE_ROOT; }; 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = ""; }; 0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = ""; }; 0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingAdminDeleteMessage.swift; sourceTree = ""; }; @@ -11961,6 +11963,7 @@ D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, 07B6BFA4D17E1353B0696C14 /* Pods */, + 0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */, ); indentWidth = 4; sourceTree = ""; @@ -18794,6 +18797,7 @@ 45069FC629D3A7C800D0DD14 /* WideMediaTileViewLayout.swift in Sources */, 45906C6B29D238560025906D /* WidePhotoCell.swift in Sources */, 76616C9D2A266A05005F7001 /* WindowManager.swift in Sources */, + A68C407AAE86CE67FAC11F79 /* LiveAudioWaveformView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift new file mode 100644 index 00000000000..2f1b4ce9b43 --- /dev/null +++ b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift @@ -0,0 +1,87 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import UIKit +import SignalUI + +class LiveAudioWaveformView: UIView { + var playedColor: UIColor = .Signal.label { + didSet { + shapeLayer.fillColor = playedColor.cgColor + } + } + + private let shapeLayer = CAShapeLayer() + private var samples: [Float] = [] + + // Config + private let sampleWidth: CGFloat = 2 + private let minSampleSpacing: CGFloat = 2 + private let minSampleHeight: CGFloat = 2 + + override init(frame: CGRect) { + super.init(frame: frame) + + shapeLayer.fillColor = playedColor.cgColor + layer.addSublayer(shapeLayer) + + clipsToBounds = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + shapeLayer.frame = bounds + redrawSamples() + } + + func appendSample(powerLevel: Float) { + // powerLevel is usually -160 to 0. + // We need to map it to a scale of 0 to 1. + let minDb: Float = -60.0 + let clampedLevel = max(minDb, powerLevel) + let normalized = (clampedLevel - minDb) / abs(minDb) + + // Optionally apply a curve (e.g. sqrt) for better visual pop + let displaySample = sqrt(normalized) + + samples.append(displaySample) + redrawSamples() + } + + private func redrawSamples() { + guard width > 0, height > 0 else { return } + + let path = UIBezierPath() + + // Calculate max samples that fit in the view + let sampleTotalWidth = sampleWidth + minSampleSpacing + let maxSamples = Int(width / sampleTotalWidth) + + // Determine the slice of samples to display (always show the most recent ones) + let startIndex = max(0, samples.count - maxSamples) + let visibleSamples = samples[startIndex...] + + for (i, sample) in visibleSamples.enumerated() { + let sampleHeight = max(minSampleHeight, height * CGFloat(sample)) + let yPos = bounds.midY - sampleHeight / 2 + + let xPos = CGFloat(i) * sampleTotalWidth + + let rect = CGRect(x: xPos, y: yPos, width: sampleWidth, height: sampleHeight) + path.append(UIBezierPath(roundedRect: rect, cornerRadius: sampleWidth / 2)) + } + + shapeLayer.path = path.cgPath + } + + func reset() { + samples.removeAll() + redrawSamples() + } +} diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 505f1f69ced..0e446313df9 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -41,6 +41,8 @@ protocol ConversationInputToolbarDelegate: AnyObject { func voiceMemoGestureWasInterrupted() + func voiceMemoAveragePower() -> Float? + func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft) // MARK: Attachments @@ -2430,6 +2432,11 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { var voiceMemoDraft: VoiceMessageInterruptedDraft? private var voiceMemoStartTime: Date? private var voiceMemoUpdateTimer: Timer? + private lazy var voiceMemoLiveWaveformView: LiveAudioWaveformView = { + let view = LiveAudioWaveformView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() private var voiceMemoTooltipView: UIView? private lazy var voiceMemoDurationLabel: UILabel = { let label = UILabel() @@ -2550,6 +2557,10 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { // Duration Label updateVoiceMemoDurationLabel() voiceMemoContentView.addSubview(voiceMemoDurationLabel) + + // Live Waveform + voiceMemoLiveWaveformView.reset() + voiceMemoContentView.addSubview(voiceMemoLiveWaveformView) // < Swipe to Cancel voiceMemoCancelLabel.alpha = 1 @@ -2563,6 +2574,12 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoDurationLabel.leadingAnchor.constraint(equalTo: redMicIconImageView.trailingAnchor, constant: 12), voiceMemoDurationLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), + + voiceMemoLiveWaveformView.leadingAnchor.constraint(equalTo: voiceMemoDurationLabel.trailingAnchor, constant: 12), + voiceMemoLiveWaveformView.trailingAnchor.constraint(lessThanOrEqualTo: voiceMemoCancelLabel.leadingAnchor, constant: -12), + voiceMemoLiveWaveformView.widthAnchor.constraint(greaterThanOrEqualToConstant: 50), + voiceMemoLiveWaveformView.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), + voiceMemoLiveWaveformView.heightAnchor.constraint(equalToConstant: 24), // X-position is configured relative to big red circle - later in this method. voiceMemoCancelLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor, constant: -2), @@ -2625,6 +2642,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { return } self.updateVoiceMemoDurationLabel() + if let power = self.inputToolbarDelegate?.voiceMemoAveragePower() { + self.voiceMemoLiveWaveformView.appendSample(powerLevel: power) + } } } diff --git a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index 54cc85a8c22..c5cc3bde1bf 100644 --- a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -260,6 +260,13 @@ extension ConversationViewController: ConversationInputToolbarDelegate { finishRecordingVoiceMessage(sendImmediately: false) } + public func voiceMemoAveragePower() -> Float? { + AssertIsOnMainThread() + guard let draft = viewState.inProgressVoiceMessage else { return nil } + draft.updateMeters() + return draft.averagePower() + } + func sendVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) { AssertIsOnMainThread() diff --git a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift index 16cc54efe14..945db518186 100644 --- a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift +++ b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift @@ -131,6 +131,13 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { private(set) var duration: TimeInterval? + func updateMeters() { + audioRecorder?.updateMeters() + } + + func averagePower() -> Float { + return audioRecorder?.averagePower(forChannel: 0) ?? -120.0 + } func convertToDraft(transaction: DBWriteTransaction) -> VoiceMessageInterruptedDraft { let directoryUrl = VoiceMessageInterruptedDraftStore.saveDraft( audioFileUrl: audioFileUrl, From 0700c0370eb2da9fc0b0b9bf96aae886f45e1af0 Mon Sep 17 00:00:00 2001 From: Chaus Date: Sun, 31 May 2026 16:45:31 +0200 Subject: [PATCH 2/3] Polish live audio waveform animation and fade masking --- .../CellViews/LiveAudioWaveformView.swift | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift index 2f1b4ce9b43..70d706a710d 100644 --- a/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift +++ b/Signal/ConversationView/CellViews/LiveAudioWaveformView.swift @@ -14,6 +14,7 @@ class LiveAudioWaveformView: UIView { } private let shapeLayer = CAShapeLayer() + private let fadeMaskLayer = CAGradientLayer() private var samples: [Float] = [] // Config @@ -24,9 +25,18 @@ class LiveAudioWaveformView: UIView { override init(frame: CGRect) { super.init(frame: frame) - shapeLayer.fillColor = playedColor.cgColor layer.addSublayer(shapeLayer) + // Add a subtle fade on the left edge so samples don't clip harshly + fadeMaskLayer.colors = [ + UIColor.clear.cgColor, + UIColor.white.cgColor + ] + fadeMaskLayer.locations = [0.0, 0.15] + fadeMaskLayer.startPoint = CGPoint(x: 0, y: 0.5) + fadeMaskLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.mask = fadeMaskLayer + clipsToBounds = true } @@ -36,42 +46,60 @@ class LiveAudioWaveformView: UIView { override func layoutSubviews() { super.layoutSubviews() + shapeLayer.fillColor = playedColor.cgColor shapeLayer.frame = bounds + fadeMaskLayer.frame = bounds redrawSamples() } func appendSample(powerLevel: Float) { // powerLevel is usually -160 to 0. // We need to map it to a scale of 0 to 1. - let minDb: Float = -60.0 + let minDb: Float = -35.0 let clampedLevel = max(minDb, powerLevel) - let normalized = (clampedLevel - minDb) / abs(minDb) + let normalized = max(0.0, (clampedLevel - minDb) / abs(minDb)) // Optionally apply a curve (e.g. sqrt) for better visual pop let displaySample = sqrt(normalized) samples.append(displaySample) redrawSamples() + + // Smoothly slide the new sample into view using Core Animation + let sampleTotalWidth = sampleWidth + minSampleSpacing + + let animation = CABasicAnimation(keyPath: "transform.translation.x") + animation.fromValue = sampleTotalWidth + animation.toValue = 0 + animation.duration = 0.2 + animation.timingFunction = CAMediaTimingFunction(name: .linear) + + shapeLayer.add(animation, forKey: "slideLeft") + shapeLayer.transform = CATransform3DIdentity } private func redrawSamples() { - guard width > 0, height > 0 else { return } + guard bounds.width > 0, bounds.height > 0 else { return } let path = UIBezierPath() // Calculate max samples that fit in the view let sampleTotalWidth = sampleWidth + minSampleSpacing - let maxSamples = Int(width / sampleTotalWidth) + let maxSamples = Int(bounds.width / sampleTotalWidth) // Determine the slice of samples to display (always show the most recent ones) let startIndex = max(0, samples.count - maxSamples) let visibleSamples = samples[startIndex...] + let totalSamplesWidth = CGFloat(visibleSamples.count) * sampleTotalWidth + let startX = bounds.width - totalSamplesWidth + for (i, sample) in visibleSamples.enumerated() { - let sampleHeight = max(minSampleHeight, height * CGFloat(sample)) + let safeSample = sample.isNaN ? 0 : sample + let sampleHeight = max(minSampleHeight, bounds.height * CGFloat(safeSample)) let yPos = bounds.midY - sampleHeight / 2 - let xPos = CGFloat(i) * sampleTotalWidth + let xPos = startX + CGFloat(i) * sampleTotalWidth let rect = CGRect(x: xPos, y: yPos, width: sampleWidth, height: sampleHeight) path.append(UIBezierPath(roundedRect: rect, cornerRadius: sampleWidth / 2)) From 693a2bb545f4ebc114b33d730f7ff62cca2de231 Mon Sep 17 00:00:00 2001 From: Chaus Date: Sun, 31 May 2026 16:45:31 +0200 Subject: [PATCH 3/3] Fix live audio waveform layout constraints during locked recording state --- .../ConversationInputToolbar.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 0e446313df9..c7897cb50e6 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -2435,6 +2435,12 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { private lazy var voiceMemoLiveWaveformView: LiveAudioWaveformView = { let view = LiveAudioWaveformView() view.translatesAutoresizingMaskIntoConstraints = false + + // Force the view to expand to fill available space + let expandConstraint = view.widthAnchor.constraint(equalToConstant: 9999) + expandConstraint.priority = .defaultLow + expandConstraint.isActive = true + return view }() private var voiceMemoTooltipView: UIView? @@ -2476,6 +2482,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { label.textAlignment = .right label.attributedText = cancelString label.translatesAutoresizingMaskIntoConstraints = false + label.setContentHuggingHigh() label.sizeToFit() return label }() @@ -2576,8 +2583,8 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoDurationLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), voiceMemoLiveWaveformView.leadingAnchor.constraint(equalTo: voiceMemoDurationLabel.trailingAnchor, constant: 12), - voiceMemoLiveWaveformView.trailingAnchor.constraint(lessThanOrEqualTo: voiceMemoCancelLabel.leadingAnchor, constant: -12), - voiceMemoLiveWaveformView.widthAnchor.constraint(greaterThanOrEqualToConstant: 50), + voiceMemoLiveWaveformView.trailingAnchor.constraint(equalTo: voiceMemoCancelLabel.leadingAnchor, constant: -12), + voiceMemoLiveWaveformView.widthAnchor.constraint(greaterThanOrEqualToConstant: 20), voiceMemoLiveWaveformView.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), voiceMemoLiveWaveformView.heightAnchor.constraint(equalToConstant: 24), @@ -2636,7 +2643,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { // Start recording timer. voiceMemoUpdateTimer?.invalidate() - voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in + voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() return @@ -2753,6 +2760,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoContentView.addConstraints([ cancelButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), cancelButton.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor, constant: -16), + voiceMemoLiveWaveformView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -12), ]) voiceMemoCancelLabel.removeFromSuperview()