add AudioEngine with AVAudioEngine playback, one-ahead buffering, skip controls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ let package = Package(
|
||||
.library(name: "BookParser", targets: ["BookParser"]),
|
||||
.library(name: "Storage", targets: ["Storage"]),
|
||||
.library(name: "Synthesizer", targets: ["Synthesizer"]),
|
||||
.library(name: "AudioEngine", targets: ["AudioEngine"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"),
|
||||
@@ -25,6 +26,10 @@ let package = Package(
|
||||
name: "VorleserKit",
|
||||
dependencies: ["Storage", "Synthesizer"]
|
||||
),
|
||||
.target(
|
||||
name: "AudioEngine",
|
||||
dependencies: ["VorleserKit", "Synthesizer", "BookParser"]
|
||||
),
|
||||
.target(
|
||||
name: "Synthesizer",
|
||||
dependencies: [
|
||||
|
||||
181
VorleserKit/Sources/AudioEngine/AudioEngine.swift
Normal file
181
VorleserKit/Sources/AudioEngine/AudioEngine.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import BookParser
|
||||
import VorleserKit
|
||||
import class Synthesizer.Synthesizer
|
||||
|
||||
/// Wraps AVAudioPCMBuffer to allow crossing isolation boundaries.
|
||||
/// Safe because buffers are created once and not mutated after construction.
|
||||
private struct SendableBuffer: @unchecked Sendable {
|
||||
let buffer: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class AudioEngine {
|
||||
public private(set) var currentPosition: CharacterOffset = 0
|
||||
public private(set) var state: PlaybackState = .idle
|
||||
|
||||
private var avEngine: AVAudioEngine?
|
||||
private var playerNode: AVAudioPlayerNode?
|
||||
private var sentences: [Sentence] = []
|
||||
private var currentSentenceIndex: Int = 0
|
||||
private var synthesizer: Synthesizer?
|
||||
private var book: Book?
|
||||
private var nextBuffer: AVAudioPCMBuffer?
|
||||
private var playbackTask: Task<Void, Never>?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func play(book: Book, from offset: CharacterOffset, using synthesizer: Synthesizer) async throws {
|
||||
stop()
|
||||
|
||||
self.book = book
|
||||
self.synthesizer = synthesizer
|
||||
self.sentences = book.sentences
|
||||
|
||||
guard let startIndex = book.sentenceIndex(containing: offset) ?? sentences.indices.first else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currentSentenceIndex = startIndex
|
||||
self.currentPosition = sentences[startIndex].range.lowerBound
|
||||
|
||||
#if os(iOS)
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
engine.attach(player)
|
||||
|
||||
let format = AVAudioFormat(standardFormatWithSampleRate: Synthesizer.sampleRate, channels: 1)!
|
||||
engine.connect(player, to: engine.mainMixerNode, format: format)
|
||||
try engine.start()
|
||||
player.play()
|
||||
|
||||
self.avEngine = engine
|
||||
self.playerNode = player
|
||||
|
||||
playbackTask = Task { [weak self] in
|
||||
await self?.playbackLoop()
|
||||
}
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
playerNode?.pause()
|
||||
state = .paused
|
||||
}
|
||||
|
||||
public func resume() {
|
||||
playerNode?.play()
|
||||
state = .playing
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
playbackTask?.cancel()
|
||||
playbackTask = nil
|
||||
playerNode?.stop()
|
||||
avEngine?.stop()
|
||||
avEngine = nil
|
||||
playerNode = nil
|
||||
nextBuffer = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
public func skipForward() {
|
||||
guard currentSentenceIndex + 1 < sentences.count else { return }
|
||||
let nextIndex = currentSentenceIndex + 1
|
||||
playerNode?.stop()
|
||||
currentSentenceIndex = nextIndex
|
||||
currentPosition = sentences[nextIndex].range.lowerBound
|
||||
nextBuffer = nil
|
||||
|
||||
playbackTask?.cancel()
|
||||
playbackTask = Task { [weak self] in
|
||||
await self?.playbackLoop()
|
||||
}
|
||||
}
|
||||
|
||||
public func skipBackward() {
|
||||
guard currentSentenceIndex > 0 else { return }
|
||||
let prevIndex = currentSentenceIndex - 1
|
||||
playerNode?.stop()
|
||||
currentSentenceIndex = prevIndex
|
||||
currentPosition = sentences[prevIndex].range.lowerBound
|
||||
nextBuffer = nil
|
||||
|
||||
playbackTask?.cancel()
|
||||
playbackTask = Task { [weak self] in
|
||||
await self?.playbackLoop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func playbackLoop() async {
|
||||
guard let synthesizer, let playerNode else { return }
|
||||
|
||||
while currentSentenceIndex < sentences.count {
|
||||
if Task.isCancelled { return }
|
||||
|
||||
let sentence = sentences[currentSentenceIndex]
|
||||
currentPosition = sentence.range.lowerBound
|
||||
state = .synthesizing
|
||||
|
||||
let buffer: AVAudioPCMBuffer
|
||||
if let prefetched = nextBuffer {
|
||||
buffer = prefetched
|
||||
nextBuffer = nil
|
||||
} else {
|
||||
do {
|
||||
let samples = try synthesizer.synthesize(text: sentence.text)
|
||||
buffer = Self.makePCMBuffer(from: samples)
|
||||
} catch {
|
||||
currentSentenceIndex += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
state = .playing
|
||||
|
||||
let prefetchTask: Task<SendableBuffer?, Never>? = {
|
||||
let nextIdx = currentSentenceIndex + 1
|
||||
guard nextIdx < sentences.count else { return nil }
|
||||
let nextText = sentences[nextIdx].text
|
||||
return Task.detached { [synthesizer] in
|
||||
guard let samples = try? synthesizer.synthesize(text: nextText) else { return nil }
|
||||
return SendableBuffer(buffer: Self.makePCMBuffer(from: samples))
|
||||
}
|
||||
}()
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
playerNode.scheduleBuffer(buffer) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
if let prefetchTask {
|
||||
nextBuffer = await prefetchTask.value?.buffer
|
||||
}
|
||||
|
||||
currentSentenceIndex += 1
|
||||
}
|
||||
|
||||
state = .idle
|
||||
}
|
||||
|
||||
private nonisolated static func makePCMBuffer(from samples: [Float]) -> AVAudioPCMBuffer {
|
||||
let format = AVAudioFormat(standardFormatWithSampleRate: Synthesizer.sampleRate, channels: 1)!
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(samples.count))!
|
||||
buffer.frameLength = AVAudioFrameCount(samples.count)
|
||||
samples.withUnsafeBufferPointer { src in
|
||||
buffer.floatChannelData![0].update(from: src.baseAddress!, count: samples.count)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
6
VorleserKit/Sources/AudioEngine/PlaybackState.swift
Normal file
6
VorleserKit/Sources/AudioEngine/PlaybackState.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
public enum PlaybackState: Sendable {
|
||||
case idle
|
||||
case synthesizing
|
||||
case playing
|
||||
case paused
|
||||
}
|
||||
Reference in New Issue
Block a user