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:
2026-03-13 22:22:07 +01:00
parent c81b78bea0
commit 8d429dc1db
3 changed files with 192 additions and 0 deletions

View File

@@ -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: [

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

View File

@@ -0,0 +1,6 @@
public enum PlaybackState: Sendable {
case idle
case synthesizing
case playing
case paused
}