917 lines
34 KiB
Swift
917 lines
34 KiB
Swift
//
|
|
// VoiceRecordView.swift
|
|
// QuickLocation
|
|
//
|
|
// Created by 八条 on 2026/6/6.
|
|
//
|
|
|
|
import UIKit
|
|
import AVFoundation
|
|
import Speech
|
|
|
|
final class VoiceRecordView: UIView, UIGestureRecognizerDelegate {
|
|
|
|
enum Phase {
|
|
case hidden
|
|
case recording
|
|
case cancelHover
|
|
case convertHover
|
|
case preview
|
|
case failed
|
|
}
|
|
|
|
enum HoverTarget {
|
|
case none
|
|
case cancel
|
|
case convert
|
|
}
|
|
|
|
private(set) var phase: Phase = .hidden
|
|
var onPreviewCancel: (() -> Void)?
|
|
var onPreviewSend: ((String) -> Void)?
|
|
|
|
private let overlayView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.65)
|
|
return view
|
|
}()
|
|
|
|
private let hillView = VoiceRecordHillView()
|
|
|
|
private let contentView = UIView()
|
|
|
|
private let hintLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = .systemFont(ofSize: 16, weight: .medium)
|
|
label.textColor = .white
|
|
label.textAlignment = .center
|
|
label.text = "松开 发送"
|
|
return label
|
|
}()
|
|
|
|
private let bubbleView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = UIColor(hexStr: "#4DA3FF")
|
|
view.layer.cornerRadius = 28
|
|
view.clipsToBounds = true
|
|
return view
|
|
}()
|
|
|
|
private let tailView = VoiceBubbleTailView()
|
|
|
|
private let dotsView = VoiceRecordingDotsView()
|
|
|
|
private let textView: UITextView = {
|
|
let view = UITextView()
|
|
view.backgroundColor = .clear
|
|
view.textColor = .white
|
|
view.font = .systemFont(ofSize: 18, weight: .medium)
|
|
view.isScrollEnabled = false
|
|
view.isEditable = false
|
|
view.textContainerInset = .zero
|
|
view.textContainer.lineFragmentPadding = 0
|
|
view.tintColor = .white
|
|
view.textAlignment = .left
|
|
return view
|
|
}()
|
|
|
|
private let waveformView = VoiceRecordWaveformView()
|
|
|
|
private let durationLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = .systemFont(ofSize: 15, weight: .medium)
|
|
label.textColor = .white
|
|
label.text = "0\""
|
|
return label
|
|
}()
|
|
|
|
private let editButton: UIButton = {
|
|
let button = UIButton(type: .custom)
|
|
button.setImage(UIImage(named: "IM/edit")?.withRenderingMode(.alwaysOriginal), for: .normal)
|
|
button.adjustsImageWhenHighlighted = false
|
|
button.isHidden = true
|
|
return button
|
|
}()
|
|
|
|
let cancelBtn = VoiceRecordCircleButton()
|
|
let convertBtn = VoiceRecordCircleButton()
|
|
let sendBtn = VoiceRecordCircleButton()
|
|
|
|
private var bubbleWidthConstraint: NSLayoutConstraint?
|
|
private var bubbleHeightConstraint: NSLayoutConstraint?
|
|
private var bubbleCenterXConstraint: NSLayoutConstraint?
|
|
private var tailCenterXConstraint: NSLayoutConstraint?
|
|
private var bubbleContentConstraints: [NSLayoutConstraint] = []
|
|
private var recognizedText: String = ""
|
|
private var duration: Int = 0
|
|
private var keyboardObserver: NSObjectProtocol?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
isHidden = true
|
|
isUserInteractionEnabled = false
|
|
backgroundColor = .clear
|
|
setupUI()
|
|
bindActions()
|
|
observeKeyboard()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
if let keyboardObserver {
|
|
NotificationCenter.default.removeObserver(keyboardObserver)
|
|
}
|
|
}
|
|
|
|
func beginRecording() {
|
|
recognizedText = ""
|
|
duration = 0
|
|
textView.text = ""
|
|
textView.isEditable = false
|
|
textView.resignFirstResponder()
|
|
waveformView.reset()
|
|
waveformView.barColor = .white
|
|
durationLabel.text = "0\""
|
|
contentView.transform = .identity
|
|
isHidden = false
|
|
isUserInteractionEnabled = false
|
|
apply(phase: .recording, animated: false)
|
|
}
|
|
|
|
func setHover(_ target: HoverTarget) {
|
|
guard phase == .recording || phase == .cancelHover || phase == .convertHover else { return }
|
|
let next: Phase
|
|
switch target {
|
|
case .none: next = .recording
|
|
case .cancel: next = .cancelHover
|
|
case .convert: next = .convertHover
|
|
}
|
|
guard phase != next else { return }
|
|
apply(phase: next)
|
|
}
|
|
|
|
func updateDuration(_ seconds: Int) {
|
|
duration = max(0, min(60, seconds))
|
|
durationLabel.text = "\(duration)\""
|
|
}
|
|
|
|
func pushLevel(_ level: CGFloat) {
|
|
waveformView.push(level: level)
|
|
}
|
|
|
|
func updateRecognizedText(_ text: String) {
|
|
recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if phase == .convertHover {
|
|
refreshConvertHoverContent()
|
|
}
|
|
}
|
|
|
|
func showPreview(text: String) {
|
|
recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
textView.text = recognizedText
|
|
isHidden = false
|
|
isUserInteractionEnabled = true
|
|
apply(phase: recognizedText.isEmpty ? .failed : .preview, animated: false)
|
|
}
|
|
|
|
func reset() {
|
|
textView.resignFirstResponder()
|
|
textView.isEditable = false
|
|
recognizedText = ""
|
|
duration = 0
|
|
waveformView.reset()
|
|
contentView.layer.removeAllAnimations()
|
|
contentView.transform = .identity
|
|
isUserInteractionEnabled = false
|
|
isHidden = true
|
|
apply(phase: .hidden, animated: false)
|
|
}
|
|
|
|
func hoverTarget(at point: CGPoint) -> HoverTarget {
|
|
let cancelFrame = cancelBtn.convert(cancelBtn.bounds.insetBy(dx: -28, dy: -28), to: self)
|
|
let convertFrame = convertBtn.convert(convertBtn.bounds.insetBy(dx: -28, dy: -28), to: self)
|
|
if !cancelBtn.isHidden, cancelFrame.contains(point) { return .cancel }
|
|
if !convertBtn.isHidden, convertFrame.contains(point) { return .convert }
|
|
return .none
|
|
}
|
|
|
|
var previewText: String {
|
|
textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
var isPreviewing: Bool {
|
|
phase == .preview || phase == .failed
|
|
}
|
|
|
|
private func bindActions() {
|
|
cancelBtn.addTarget(self, action: #selector(handlePreviewCancel), for: .touchUpInside)
|
|
sendBtn.addTarget(self, action: #selector(handlePreviewSend), for: .touchUpInside)
|
|
editButton.addTarget(self, action: #selector(handleEdit), for: .touchUpInside)
|
|
|
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap))
|
|
tap.cancelsTouchesInView = false
|
|
tap.delegate = self
|
|
addGestureRecognizer(tap)
|
|
}
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
guard textView.isFirstResponder else { return false }
|
|
var view = touch.view
|
|
while let current = view {
|
|
if current is UIControl { return false }
|
|
if current === textView || current === bubbleView { return false }
|
|
view = current.superview
|
|
}
|
|
return true
|
|
}
|
|
|
|
@objc private func handleBackgroundTap() {
|
|
guard textView.isFirstResponder else { return }
|
|
endEditing(true)
|
|
}
|
|
|
|
@objc private func handlePreviewCancel() {
|
|
guard phase == .preview || phase == .failed else { return }
|
|
contentView.layer.removeAllAnimations()
|
|
contentView.transform = .identity
|
|
endEditing(true)
|
|
onPreviewCancel?()
|
|
}
|
|
|
|
@objc private func handlePreviewSend() {
|
|
guard phase == .preview else { return }
|
|
let text = previewText
|
|
guard !text.isEmpty else { return }
|
|
contentView.layer.removeAllAnimations()
|
|
contentView.transform = .identity
|
|
endEditing(true)
|
|
onPreviewSend?(text)
|
|
}
|
|
|
|
@objc private func handleEdit() {
|
|
guard phase == .preview else { return }
|
|
if textView.isFirstResponder {
|
|
textView.resignFirstResponder()
|
|
return
|
|
}
|
|
textView.isEditable = true
|
|
textView.isUserInteractionEnabled = true
|
|
textView.becomeFirstResponder()
|
|
}
|
|
|
|
private func setupUI() {
|
|
addSubview(overlayView)
|
|
addSubview(hillView)
|
|
addSubview(contentView)
|
|
contentView.addSubview(hintLabel)
|
|
contentView.addSubview(cancelBtn)
|
|
contentView.addSubview(convertBtn)
|
|
contentView.addSubview(sendBtn)
|
|
contentView.addSubview(tailView)
|
|
contentView.addSubview(bubbleView)
|
|
bubbleView.addSubview(dotsView)
|
|
bubbleView.addSubview(textView)
|
|
bubbleView.addSubview(waveformView)
|
|
bubbleView.addSubview(durationLabel)
|
|
bubbleView.addSubview(editButton)
|
|
|
|
[overlayView, hillView, contentView, hintLabel, cancelBtn, convertBtn, sendBtn, tailView, bubbleView,
|
|
dotsView, textView, waveformView, durationLabel, editButton].forEach {
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
}
|
|
|
|
overlayView.layoutChain.edges()
|
|
contentView.layoutChain.edges()
|
|
|
|
let hillSize = kScreenWidth + 180
|
|
NSLayoutConstraint.activate([
|
|
hillView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
hillView.widthAnchor.constraint(equalToConstant: hillSize),
|
|
hillView.heightAnchor.constraint(equalToConstant: hillSize),
|
|
hillView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: hillSize - 118)
|
|
])
|
|
|
|
hintLabel.layoutChain
|
|
.centerX()
|
|
.bottom(46 + kSafeBottomMargin)
|
|
.height(22)
|
|
|
|
cancelBtn.layoutChain
|
|
.left(48)
|
|
.bottomToTopOfView(hintLabel, offset: -80)
|
|
.width(80)
|
|
.height(80)
|
|
|
|
convertBtn.layoutChain
|
|
.right(48)
|
|
.bottomToView(cancelBtn)
|
|
.width(80)
|
|
.height(80)
|
|
|
|
sendBtn.layoutChain
|
|
.right(48)
|
|
.bottomToView(cancelBtn)
|
|
.width(80)
|
|
.height(80)
|
|
|
|
bubbleCenterXConstraint = bubbleView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
|
|
bubbleWidthConstraint = bubbleView.widthAnchor.constraint(equalToConstant: 228)
|
|
bubbleHeightConstraint = bubbleView.heightAnchor.constraint(equalToConstant: 58)
|
|
NSLayoutConstraint.activate([
|
|
bubbleView.bottomAnchor.constraint(equalTo: cancelBtn.topAnchor, constant: -26),
|
|
bubbleCenterXConstraint!,
|
|
bubbleWidthConstraint!,
|
|
bubbleHeightConstraint!
|
|
])
|
|
|
|
tailCenterXConstraint = tailView.centerXAnchor.constraint(equalTo: bubbleView.centerXAnchor)
|
|
NSLayoutConstraint.activate([
|
|
tailView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -1),
|
|
tailView.widthAnchor.constraint(equalToConstant: 16),
|
|
tailView.heightAnchor.constraint(equalToConstant: 9),
|
|
tailCenterXConstraint!
|
|
])
|
|
|
|
cancelBtn.configure(title: "取消", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B"))
|
|
convertBtn.configure(title: "转文字", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B"))
|
|
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white)
|
|
sendBtn.isHidden = true
|
|
}
|
|
|
|
private func observeKeyboard() {
|
|
keyboardObserver = NotificationCenter.default.addObserver(
|
|
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] note in
|
|
self?.handleKeyboard(note)
|
|
}
|
|
}
|
|
|
|
private func handleKeyboard(_ note: Notification) {
|
|
guard !isHidden, phase == .preview || phase == .failed,
|
|
let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
|
|
else { return }
|
|
let duration = (note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
|
|
let curveRaw = (note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt) ?? 7
|
|
let endInView = convert(frame, from: nil)
|
|
let overlap = max(0, bounds.maxY - endInView.minY)
|
|
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: curveRaw << 16)) {
|
|
self.contentView.transform = overlap > 0 ? CGAffineTransform(translationX: 0, y: -overlap) : .identity
|
|
}
|
|
}
|
|
|
|
private func apply(phase: Phase, animated: Bool = true) {
|
|
let changed = self.phase != phase
|
|
self.phase = phase
|
|
switch phase {
|
|
case .hidden:
|
|
dotsView.stop()
|
|
case .recording:
|
|
hintLabel.text = "松开 发送"
|
|
hintLabel.isHidden = false
|
|
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
|
|
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
|
|
convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
|
|
convertBtn.isHidden = false
|
|
sendBtn.isHidden = true
|
|
cancelBtn.isUserInteractionEnabled = false
|
|
sendBtn.isUserInteractionEnabled = false
|
|
layoutRecordingBubble(showText: false)
|
|
case .cancelHover:
|
|
hintLabel.text = "松开 取消"
|
|
hintLabel.isHidden = false
|
|
setBubbleColor(UIColor(hexStr: "#FF3B30"))
|
|
cancelBtn.setEmphasized(true, color: UIColor(hexStr: "#FF3B30"))
|
|
convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
|
|
convertBtn.isHidden = false
|
|
sendBtn.isHidden = true
|
|
layoutRecordingBubble(showText: false)
|
|
case .convertHover:
|
|
hintLabel.text = "松开 编辑文字"
|
|
hintLabel.isHidden = false
|
|
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
|
|
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
|
|
convertBtn.setEmphasized(true, color: UIColor(hexStr: "#4DA3FF"))
|
|
convertBtn.isHidden = false
|
|
sendBtn.isHidden = true
|
|
refreshConvertHoverContent()
|
|
case .preview:
|
|
hintLabel.text = "松开 取消"
|
|
hintLabel.isHidden = false
|
|
setBubbleColor(UIColor(hexStr: "#4DA3FF"))
|
|
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
|
|
convertBtn.isHidden = true
|
|
sendBtn.isHidden = false
|
|
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white)
|
|
sendBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF"))
|
|
sendBtn.isEnabled = true
|
|
cancelBtn.isUserInteractionEnabled = true
|
|
sendBtn.isUserInteractionEnabled = true
|
|
layoutPreviewBubble(failed: false)
|
|
case .failed:
|
|
hintLabel.text = "松开 取消"
|
|
hintLabel.isHidden = false
|
|
setBubbleColor(UIColor(hexStr: "#FF3B30"))
|
|
cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30"))
|
|
convertBtn.isHidden = true
|
|
sendBtn.isHidden = false
|
|
sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#C4473E"), normalTitleColor: .white)
|
|
sendBtn.setEmphasized(false, color: UIColor(hexStr: "#C4473E"))
|
|
sendBtn.isEnabled = false
|
|
cancelBtn.isUserInteractionEnabled = true
|
|
sendBtn.isUserInteractionEnabled = false
|
|
layoutPreviewBubble(failed: true)
|
|
}
|
|
let sendScale: CGAffineTransform = phase == .preview
|
|
? CGAffineTransform(scaleX: 1.22, y: 1.22)
|
|
: .identity
|
|
if animated, changed, phase != .hidden {
|
|
UIView.animate(withDuration: 0.18, delay: 0, options: [.allowUserInteraction, .curveEaseOut]) {
|
|
self.layoutIfNeeded()
|
|
self.sendBtn.transform = sendScale
|
|
}
|
|
} else {
|
|
layoutIfNeeded()
|
|
sendBtn.transform = sendScale
|
|
}
|
|
}
|
|
|
|
private func refreshConvertHoverContent() {
|
|
textView.text = recognizedText
|
|
layoutRecordingBubble(showText: true)
|
|
}
|
|
|
|
private func layoutRecordingBubble(showText: Bool) {
|
|
let showLiveText = showText && !recognizedText.isEmpty
|
|
dotsView.isHidden = !showText
|
|
if showText { dotsView.start() } else { dotsView.stop() }
|
|
textView.isHidden = !showLiveText
|
|
textView.isEditable = false
|
|
textView.isUserInteractionEnabled = false
|
|
textView.textAlignment = .left
|
|
editButton.isHidden = true
|
|
waveformView.isHidden = false
|
|
durationLabel.isHidden = false
|
|
durationLabel.textColor = .white
|
|
waveformView.barColor = .white
|
|
waveformView.style = showText ? .mini : .full
|
|
|
|
if showText {
|
|
bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318)
|
|
bubbleHeightConstraint?.constant = showLiveText ? 118 : 96
|
|
bubbleView.layer.cornerRadius = 24
|
|
bubbleCenterXConstraint?.constant = 8
|
|
tailCenterXConstraint?.constant = 52
|
|
} else if phase == .cancelHover {
|
|
bubbleWidthConstraint?.constant = 228
|
|
bubbleHeightConstraint?.constant = 58
|
|
bubbleView.layer.cornerRadius = 29
|
|
bubbleCenterXConstraint?.constant = -36
|
|
tailCenterXConstraint?.constant = -8
|
|
} else {
|
|
bubbleWidthConstraint?.constant = 228
|
|
bubbleHeightConstraint?.constant = 58
|
|
bubbleView.layer.cornerRadius = 29
|
|
bubbleCenterXConstraint?.constant = 0
|
|
tailCenterXConstraint?.constant = 0
|
|
}
|
|
installBubbleContentConstraints(showText: showText, preview: false, failed: false)
|
|
}
|
|
|
|
private func layoutPreviewBubble(failed: Bool) {
|
|
dotsView.stop()
|
|
dotsView.isHidden = true
|
|
textView.isHidden = false
|
|
waveformView.isHidden = true
|
|
durationLabel.isHidden = true
|
|
editButton.isHidden = failed
|
|
textView.isEditable = false
|
|
textView.isUserInteractionEnabled = !failed
|
|
textView.text = failed ? "未识别到文字" : recognizedText
|
|
textView.textAlignment = failed ? .center : .left
|
|
bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318)
|
|
bubbleHeightConstraint?.constant = failed ? 72 : max(108, previewTextHeight() + 44)
|
|
bubbleView.layer.cornerRadius = 24
|
|
bubbleCenterXConstraint?.constant = 8
|
|
tailCenterXConstraint?.constant = 52
|
|
installBubbleContentConstraints(showText: false, preview: true, failed: failed)
|
|
}
|
|
|
|
private func previewTextHeight() -> CGFloat {
|
|
let width = min(kScreenWidth - 48, 318) - 52
|
|
let text = recognizedText as NSString
|
|
let rect = text.boundingRect(
|
|
with: CGSize(width: width, height: 160),
|
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
|
attributes: [.font: UIFont.systemFont(ofSize: 18, weight: .medium)],
|
|
context: nil
|
|
)
|
|
return ceil(rect.height)
|
|
}
|
|
|
|
private func installBubbleContentConstraints(showText: Bool, preview: Bool, failed: Bool) {
|
|
NSLayoutConstraint.deactivate(bubbleContentConstraints)
|
|
bubbleContentConstraints.removeAll()
|
|
|
|
if preview {
|
|
if failed {
|
|
bubbleContentConstraints.append(contentsOf: [
|
|
textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
|
|
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
|
|
textView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor)
|
|
])
|
|
} else {
|
|
bubbleContentConstraints.append(contentsOf: [
|
|
textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
|
|
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -44),
|
|
textView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 16),
|
|
editButton.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
|
|
editButton.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -16),
|
|
editButton.widthAnchor.constraint(equalToConstant: 22),
|
|
editButton.heightAnchor.constraint(equalToConstant: 22)
|
|
])
|
|
}
|
|
} else if showText {
|
|
bubbleContentConstraints.append(contentsOf: [
|
|
dotsView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
|
|
dotsView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 18),
|
|
dotsView.widthAnchor.constraint(equalToConstant: 28),
|
|
dotsView.heightAnchor.constraint(equalToConstant: 10),
|
|
textView.leadingAnchor.constraint(equalTo: dotsView.trailingAnchor, constant: 8),
|
|
textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18),
|
|
textView.centerYAnchor.constraint(equalTo: dotsView.centerYAnchor),
|
|
durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16),
|
|
durationLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -14),
|
|
waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8),
|
|
waveformView.centerYAnchor.constraint(equalTo: durationLabel.centerYAnchor),
|
|
waveformView.widthAnchor.constraint(equalToConstant: 92),
|
|
waveformView.heightAnchor.constraint(equalToConstant: 16)
|
|
])
|
|
} else {
|
|
bubbleContentConstraints.append(contentsOf: [
|
|
durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16),
|
|
durationLabel.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor),
|
|
waveformView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18),
|
|
waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8),
|
|
waveformView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor),
|
|
waveformView.heightAnchor.constraint(equalToConstant: 28)
|
|
])
|
|
}
|
|
NSLayoutConstraint.activate(bubbleContentConstraints)
|
|
}
|
|
|
|
private func setBubbleColor(_ color: UIColor) {
|
|
bubbleView.backgroundColor = color
|
|
tailView.fillColor = color
|
|
}
|
|
|
|
}
|
|
|
|
final class VoiceRecordHillView: UIView {
|
|
private let gradientLayer = CAGradientLayer()
|
|
private let rimLayer = CAShapeLayer()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
clipsToBounds = true
|
|
gradientLayer.colors = [
|
|
UIColor(white: 0.455, alpha: 1).cgColor,
|
|
UIColor(white: 0.75, alpha: 1).cgColor
|
|
]
|
|
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
|
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.26)
|
|
layer.addSublayer(gradientLayer)
|
|
rimLayer.fillColor = UIColor.clear.cgColor
|
|
rimLayer.strokeColor = UIColor.white.withAlphaComponent(0.92).cgColor
|
|
rimLayer.lineWidth = 1.5
|
|
layer.addSublayer(rimLayer)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
layer.cornerRadius = bounds.width / 2
|
|
gradientLayer.frame = bounds
|
|
rimLayer.frame = bounds
|
|
rimLayer.path = UIBezierPath(ovalIn: bounds.insetBy(dx: 1, dy: 1)).cgPath
|
|
}
|
|
}
|
|
|
|
final class VoiceRecordCircleButton: UIButton {
|
|
private let ringView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .clear
|
|
view.layer.borderWidth = 8
|
|
view.isHidden = true
|
|
view.isUserInteractionEnabled = false
|
|
return view
|
|
}()
|
|
|
|
private var normalColor: UIColor = UIColor(hexStr: "#F3F6F8")
|
|
private var normalTitleColor: UIColor = UIColor(hexStr: "#2C3A4B")
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
|
|
layer.masksToBounds = false
|
|
addSubview(ringView)
|
|
ringView.translatesAutoresizingMaskIntoConstraints = false
|
|
ringView.layoutChain
|
|
.centerX()
|
|
.centerY()
|
|
.widthToView(self, offset: 22)
|
|
.heightToView(self, offset: 22)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
layer.cornerRadius = min(bounds.width, bounds.height) / 2
|
|
ringView.layer.cornerRadius = min(ringView.bounds.width, ringView.bounds.height) / 2
|
|
}
|
|
|
|
func configure(title: String, normalColor: UIColor, normalTitleColor: UIColor) {
|
|
self.normalColor = normalColor
|
|
self.normalTitleColor = normalTitleColor
|
|
setTitle(title, for: .normal)
|
|
setTitleColor(normalTitleColor, for: .normal)
|
|
backgroundColor = normalColor
|
|
}
|
|
|
|
func setEmphasized(_ emphasized: Bool, color: UIColor) {
|
|
if emphasized {
|
|
backgroundColor = color
|
|
setTitleColor(.white, for: .normal)
|
|
ringView.isHidden = false
|
|
ringView.layer.borderColor = color.withAlphaComponent(0.35).cgColor
|
|
transform = CGAffineTransform(scaleX: 1.16, y: 1.16)
|
|
} else {
|
|
backgroundColor = normalColor
|
|
setTitleColor(normalTitleColor, for: .normal)
|
|
ringView.isHidden = true
|
|
transform = .identity
|
|
}
|
|
}
|
|
}
|
|
|
|
final class VoiceBubbleTailView: UIView {
|
|
var fillColor: UIColor = UIColor(hexStr: "#4DA3FF") {
|
|
didSet { setNeedsDisplay() }
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
let path = UIBezierPath()
|
|
path.move(to: CGPoint(x: 0, y: 0))
|
|
path.addLine(to: CGPoint(x: rect.maxX, y: 0))
|
|
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
|
|
path.close()
|
|
fillColor.setFill()
|
|
path.fill()
|
|
}
|
|
}
|
|
|
|
final class VoiceRecordWaveformView: UIView {
|
|
enum Style {
|
|
case full
|
|
case mini
|
|
}
|
|
|
|
var style: Style = .full {
|
|
didSet { setNeedsDisplay() }
|
|
}
|
|
|
|
private var levels: [CGFloat] = Array(repeating: 0.18, count: 21)
|
|
var barColor: UIColor = .white {
|
|
didSet { setNeedsDisplay() }
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func push(level: CGFloat) {
|
|
levels.removeFirst()
|
|
levels.append(min(1, max(0.12, level)))
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
func reset() {
|
|
levels = Array(repeating: 0.18, count: 21)
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return }
|
|
let count = levels.count
|
|
let mid = CGFloat(count - 1) / 2
|
|
let spacing = rect.width / CGFloat(count)
|
|
for (index, level) in levels.enumerated() {
|
|
let dist = abs(CGFloat(index) - mid) / mid
|
|
let envelope = CGFloat(exp(-Double(dist * dist * 3.8)))
|
|
let height = max(style == .mini ? 3 : 4, rect.height * envelope * max(level, 0.16))
|
|
let x = CGFloat(index) * spacing + spacing / 2
|
|
let lineWidth: CGFloat
|
|
if dist < 0.12 {
|
|
lineWidth = style == .mini ? 3.2 : 5
|
|
} else if dist < 0.38 {
|
|
lineWidth = style == .mini ? 2.4 : 3.4
|
|
} else {
|
|
lineWidth = style == .mini ? 2 : 2.6
|
|
}
|
|
context.setStrokeColor(barColor.withAlphaComponent(0.45 + 0.55 * envelope).cgColor)
|
|
context.setLineWidth(lineWidth)
|
|
context.setLineCap(.round)
|
|
context.move(to: CGPoint(x: x, y: rect.midY - height / 2))
|
|
context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2))
|
|
context.strokePath()
|
|
}
|
|
}
|
|
}
|
|
|
|
final class VoiceRecordingDotsView: UIView {
|
|
private let dots = [UIView(), UIView(), UIView()]
|
|
private var isAnimating = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
dots.enumerated().forEach { index, dot in
|
|
dot.backgroundColor = UIColor.white.withAlphaComponent(index == 0 ? 1 : 0.55)
|
|
dot.layer.cornerRadius = 3
|
|
addSubview(dot)
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
let size: CGFloat = 6
|
|
let gap: CGFloat = 5
|
|
dots.enumerated().forEach { index, dot in
|
|
dot.frame = CGRect(x: CGFloat(index) * (size + gap), y: (bounds.height - size) / 2, width: size, height: size)
|
|
}
|
|
}
|
|
|
|
func start() {
|
|
guard !isAnimating else { return }
|
|
isAnimating = true
|
|
animateDots()
|
|
}
|
|
|
|
func stop() {
|
|
isAnimating = false
|
|
layer.removeAllAnimations()
|
|
dots.forEach { $0.layer.removeAllAnimations() }
|
|
dots.enumerated().forEach { index, dot in
|
|
dot.alpha = index == 0 ? 1 : 0.55
|
|
}
|
|
}
|
|
|
|
private func animateDots() {
|
|
guard isAnimating else { return }
|
|
for (index, dot) in dots.enumerated() {
|
|
UIView.animate(withDuration: 0.35, delay: Double(index) * 0.12, options: [.repeat, .autoreverse]) {
|
|
dot.alpha = 0.25
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ChatVoiceCapture: @unchecked Sendable {
|
|
private let engine = AVAudioEngine()
|
|
private var audioFile: AVAudioFile?
|
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
|
private var recognitionTask: SFSpeechRecognitionTask?
|
|
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN"))
|
|
private var startDate: Date?
|
|
private var tapInstalled = false
|
|
|
|
private(set) var latestText = ""
|
|
var onLevel: ((CGFloat) -> Void)?
|
|
var onPartialText: ((String) -> Void)?
|
|
|
|
var duration: Int {
|
|
guard let startDate else { return 0 }
|
|
return max(0, Int(Date().timeIntervalSince(startDate)))
|
|
}
|
|
|
|
func start(fileURL: URL, enableSpeech: Bool) throws {
|
|
stop(finishRecognition: false)
|
|
latestText = ""
|
|
startDate = Date()
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth])
|
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
|
|
let input = engine.inputNode
|
|
let format = input.outputFormat(forBus: 0)
|
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
|
throw NSError(domain: "ChatVoiceCapture", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio format"])
|
|
}
|
|
|
|
audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings)
|
|
|
|
if enableSpeech, let recognizer, recognizer.isAvailable {
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
request.shouldReportPartialResults = true
|
|
request.taskHint = .dictation
|
|
recognitionRequest = request
|
|
recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, _ in
|
|
guard let text = result?.bestTranscription.formattedString, !text.isEmpty else { return }
|
|
DispatchQueue.main.async {
|
|
self?.latestText = text
|
|
self?.onPartialText?(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
|
guard let self else { return }
|
|
try? self.audioFile?.write(from: buffer)
|
|
self.recognitionRequest?.append(buffer)
|
|
let level = ChatVoiceCapture.rms(from: buffer)
|
|
DispatchQueue.main.async {
|
|
self.onLevel?(level)
|
|
}
|
|
}
|
|
tapInstalled = true
|
|
engine.prepare()
|
|
try engine.start()
|
|
}
|
|
|
|
func stop(finishRecognition: Bool) {
|
|
if tapInstalled {
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
tapInstalled = false
|
|
}
|
|
if engine.isRunning {
|
|
engine.stop()
|
|
}
|
|
if finishRecognition {
|
|
recognitionRequest?.endAudio()
|
|
recognitionRequest = nil
|
|
} else {
|
|
recognitionTask?.cancel()
|
|
recognitionRequest = nil
|
|
recognitionTask = nil
|
|
latestText = ""
|
|
}
|
|
audioFile = nil
|
|
startDate = nil
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
|
|
static func rms(from buffer: AVAudioPCMBuffer) -> CGFloat {
|
|
let n = Int(buffer.frameLength)
|
|
guard n > 0 else { return 0.12 }
|
|
if let data = buffer.floatChannelData?[0] {
|
|
var sum: Float = 0
|
|
for i in 0..<n {
|
|
let sample = data[i]
|
|
sum += sample * sample
|
|
}
|
|
return CGFloat(min(1, max(0.12, sqrt(sum / Float(n)) * 5)))
|
|
}
|
|
if let data = buffer.int16ChannelData?[0] {
|
|
var sum: Float = 0
|
|
for i in 0..<n {
|
|
let sample = Float(data[i]) / 32768
|
|
sum += sample * sample
|
|
}
|
|
return CGFloat(min(1, max(0.12, sqrt(sum / Float(n)) * 5)))
|
|
}
|
|
return 0.12
|
|
}
|
|
}
|