LiveWaveformView.swift 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import SwiftUI
  2. // MARK: - LiveWaveformView
  3. /// A real-time audio waveform visualization rendered via SwiftUI `Canvas`.
  4. /// Uses a highly minimalist, monochrome, line-drawn style suitable for business applications.
  5. struct LiveWaveformView: View {
  6. /// Audio amplitude samples (0.0–1.0). Most recent sample is last.
  7. let samples: [Float]
  8. /// Height of the waveform view.
  9. var height: CGFloat = 80
  10. /// Width of each bar in points.
  11. private let barWidth: CGFloat = 1.0
  12. /// Spacing between bars in points.
  13. private let barSpacing: CGFloat = 2.0
  14. /// Minimum bar height to ensure visibility even at zero amplitude.
  15. private let minBarHeight: CGFloat = 1.0
  16. var body: some View {
  17. Canvas { context, size in
  18. let totalBarSlot = barWidth + barSpacing
  19. let maxBars = Int(size.width / totalBarSlot)
  20. let maxHeight = size.height
  21. let centerY = size.height / 2
  22. // Take the most recent N samples that fit on screen
  23. let visibleSamples: [Float]
  24. if samples.count > maxBars {
  25. visibleSamples = Array(samples.suffix(maxBars))
  26. } else {
  27. visibleSamples = samples
  28. }
  29. let barCount = visibleSamples.count
  30. // Draw center reference line
  31. let centerLinePath = Path { path in
  32. path.move(to: CGPoint(x: 0, y: centerY))
  33. path.addLine(to: CGPoint(x: size.width, y: centerY))
  34. }
  35. context.stroke(
  36. centerLinePath,
  37. with: .color(Color.primary.opacity(0.1)),
  38. lineWidth: 1
  39. )
  40. // Draw bars right-aligned (most recent on the right)
  41. for index in 0..<barCount {
  42. let sample = CGFloat(max(visibleSamples[index], 0))
  43. let barHeight = max(sample * maxHeight * 0.75, minBarHeight)
  44. // Position: right-aligned
  45. let xOffset = size.width - CGFloat(barCount - index) * totalBarSlot
  46. let barRect = CGRect(
  47. x: xOffset,
  48. y: centerY - barHeight / 2,
  49. width: barWidth,
  50. height: barHeight
  51. )
  52. let barPath = Path(barRect)
  53. // Opacity fade on the left edge (older samples fade out)
  54. let fadeWidth = 0.25 // 25% of visible area fades
  55. let opacity: Double
  56. if barCount > 1 {
  57. let normalizedX = CGFloat(index) / CGFloat(barCount - 1)
  58. if normalizedX < fadeWidth {
  59. opacity = Double(normalizedX / fadeWidth)
  60. } else {
  61. opacity = 1.0
  62. }
  63. } else {
  64. opacity = 1.0
  65. }
  66. context.opacity = opacity
  67. // Simple monochrome fill (Color.primary)
  68. context.fill(barPath, with: .color(Color.primary.opacity(0.85)))
  69. context.opacity = 1.0 // Reset
  70. }
  71. }
  72. .frame(height: height)
  73. .background(
  74. RoundedRectangle(cornerRadius: 8)
  75. .fill(Color.primary.opacity(0.01)) // Semi-transparent minimal background
  76. )
  77. .businessBorder(cornerRadius: 8) // Thin outlined border
  78. }
  79. }
  80. // MARK: - Preview
  81. #Preview("Waveform — Active") {
  82. ZStack {
  83. Color.spaceBlack.ignoresSafeArea()
  84. VStack(spacing: 20) {
  85. LiveWaveformView(
  86. samples: (0..<80).map { _ in Float.random(in: 0.05...0.9) }
  87. )
  88. .padding(.horizontal)
  89. LiveWaveformView(
  90. samples: (0..<40).map { i in
  91. Float(sin(Double(i) * 0.3) * 0.4 + 0.5)
  92. }
  93. )
  94. .padding(.horizontal)
  95. }
  96. }
  97. }