Created
April 10, 2025 07:47
-
-
Save Codelaby/343ab24724f4c86ec3f5014e0c254441 to your computer and use it in GitHub Desktop.
Wave form Scrubber
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// AudiowaveDemo.swift | |
// RangeSlidersPlaygroun | |
// | |
// Created by Codelaby on 9/4/25. | |
// | |
import SwiftUI | |
import AVKit | |
struct AudiowaveDemo: View { | |
@State private var progress: CGFloat = 0 | |
var body: some View { | |
NavigationStack { | |
if let audioURL = remoteAudioURL { | |
List { | |
Section("Audio.wav") { | |
WaveformScrubber(url: audioURL, progress: $progress) { info in | |
print(info) | |
} onGestureActive: { status in | |
print(status) | |
} | |
.frame(height: 60) | |
} | |
} | |
} | |
} | |
} | |
var localAudioURL: URL? { | |
Bundle.main.url(forResource: "sample-15s", withExtension: "wav") | |
} | |
var localMP3AudioURL: URL? { | |
Bundle.main.url(forResource: "Yến Vô Hiết - 燕无歇 - Mây Bae Cover Official", withExtension: "mp3") | |
} | |
var remoteAudioURL: URL? { | |
URL(string: "https://cdn.freesound.org/previews/797/797925_10996917-lq.mp3") | |
} | |
} | |
#Preview { | |
AudiowaveDemo() | |
} | |
//sample-15s | |
struct WaveformScrubber: View { | |
var config: Config = .init() | |
var url: URL | |
@Binding var progress: CGFloat | |
var info: (AudioInfo) -> () = { _ in } | |
var onGestureActive: (Bool) -> () = { _ in } | |
/// View Properties | |
@State private var samples: [Float] = [] | |
@State private var dowsizedSample: [Float] = [] | |
var body: some View { | |
//Text("Hello world!") | |
ZStack { | |
WaveformShape(samples: dowsizedSample) | |
} | |
.frame(maxWidth: .infinity) | |
// .onAppear { | |
// initializedAudioFile() | |
// } | |
.onGeometryChange(for: CGSize.self) { | |
$0.size | |
} action: { newValue in | |
initializedAudioFile(newValue) | |
} | |
} | |
struct Config { | |
var spacing: Float = 2 | |
var shapeWidth: Float = 2 | |
var activeTint: Color = .black | |
var inactiveTint: Color = .gray | |
} | |
struct AudioInfo { | |
var duration: TimeInterval = 0 | |
} | |
} | |
// MARK: Wave form shape | |
fileprivate struct WaveformShape: Shape { | |
var samples: [Float] | |
var spacing: Float = 2 | |
var width: Float = 2 | |
nonisolated func path(in rect: CGRect) -> Path { | |
Path { path in | |
var x: CGFloat = 0 | |
for sample in samples { | |
let height = max(CGFloat(sample) * rect.height, 1) | |
path.addRect(CGRect( | |
origin: .init(x: x + CGFloat(width), y: -height / 2), | |
size: .init(width: CGFloat(width), height: height) | |
)) | |
x += CGFloat(spacing + width) | |
} | |
} | |
.offsetBy(dx: 0, dy: rect.height / 2) | |
} | |
} | |
// MARK: Extract Audio data | |
extension WaveformScrubber { | |
func initializedAudioFile(_ size: CGSize) { | |
guard samples.isEmpty else { return } | |
Task.detached(priority: .high) { | |
do { | |
let audioFile = try AVAudioFile(forReading: url) | |
let audioInfo = extractAudioInfo(audioFile) | |
let samples = try extractAudioSamples(audioFile) | |
//print(samples.count) | |
let downSampleCount = Int(Float(size.width) / (config.spacing + config.shapeWidth)) | |
let downSamples = downSampleAudioSamples(samples, downSampleCount) | |
//print(downSamples.count) | |
await MainActor.run { | |
self.samples = samples | |
self.dowsizedSample = downSamples | |
self.info(audioInfo) | |
} | |
} catch { | |
print("🐞error", error.localizedDescription) | |
} | |
} | |
} | |
nonisolated func extractAudioSamples(_ file: AVAudioFile) throws -> [Float] { | |
let format = file.processingFormat | |
let frameCount = UInt32(file.length) | |
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { | |
return [] | |
} | |
try file.read(into: buffer) | |
if let channel = buffer.floatChannelData { | |
let samples = Array(UnsafeBufferPointer(start: channel[0], count: Int(buffer.frameLength))) | |
return samples | |
} | |
return [] | |
} | |
nonisolated func downSampleAudioSamples(_ samples: [Float], _ count: Int) -> [Float] { | |
let chunk = samples.count / count | |
var downsamples: [Float] = [] | |
for index in 0..<count { | |
let start = index * chunk | |
let end = min((index + 1) * chunk, samples.count) | |
let chunkSamples = samples[start..<end] | |
let maxValue = chunkSamples.max() ?? 0 | |
downsamples.append(maxValue) | |
} | |
return downsamples | |
} | |
nonisolated func extractAudioInfo(_ file: AVAudioFile) -> AudioInfo { | |
let format = file.processingFormat | |
let sampleRate = format.sampleRate | |
let duration = file.length / Int64(sampleRate) | |
return .init(duration: TimeInterval(duration)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment