Skip to content
Open
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
4 changes: 4 additions & 0 deletions Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -4230,6 +4231,7 @@
0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = "<group>"; };
047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = "<group>"; };
0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = "<group>"; };
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 = "<group>"; };
0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = "<group>"; };
0484CED12F44DCFF009AB2CB /* OutgoingAdminDeleteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingAdminDeleteMessage.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -11961,6 +11963,7 @@
D221A08C169C9E5E00537ABF /* Frameworks */,
D221A08A169C9E5E00537ABF /* Products */,
07B6BFA4D17E1353B0696C14 /* Pods */,
0484486DA7CD91AB4D8E771E /* LiveAudioWaveformView.swift */,
);
indentWidth = 4;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
115 changes: 115 additions & 0 deletions Signal/ConversationView/CellViews/LiveAudioWaveformView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// 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 let fadeMaskLayer = CAGradientLayer()
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)

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
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

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 = -35.0
let clampedLevel = max(minDb, powerLevel)
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 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(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 safeSample = sample.isNaN ? 0 : sample
let sampleHeight = max(minSampleHeight, bounds.height * CGFloat(safeSample))
let yPos = bounds.midY - sampleHeight / 2

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))
}

shapeLayer.path = path.cgPath
}

func reset() {
samples.removeAll()
redrawSamples()
}
}
30 changes: 29 additions & 1 deletion Signal/ConversationView/ConversationInputToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ protocol ConversationInputToolbarDelegate: AnyObject {

func voiceMemoGestureWasInterrupted()

func voiceMemoAveragePower() -> Float?

func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft)

// MARK: Attachments
Expand Down Expand Up @@ -2430,6 +2432,17 @@ 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

// 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?
private lazy var voiceMemoDurationLabel: UILabel = {
let label = UILabel()
Expand Down Expand Up @@ -2469,6 +2482,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
label.textAlignment = .right
label.attributedText = cancelString
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentHuggingHigh()
label.sizeToFit()
return label
}()
Expand Down Expand Up @@ -2550,6 +2564,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
Expand All @@ -2563,6 +2581,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(equalTo: voiceMemoCancelLabel.leadingAnchor, constant: -12),
voiceMemoLiveWaveformView.widthAnchor.constraint(greaterThanOrEqualToConstant: 20),
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),
Expand Down Expand Up @@ -2619,12 +2643,15 @@ 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
}
self.updateVoiceMemoDurationLabel()
if let power = self.inputToolbarDelegate?.voiceMemoAveragePower() {
self.voiceMemoLiveWaveformView.appendSample(powerLevel: power)
}
}
}

Expand Down Expand Up @@ -2733,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down