diff --git a/VorleserKit/Package.swift b/VorleserKit/Package.swift index 3ccd84c..ca8d72c 100644 --- a/VorleserKit/Package.swift +++ b/VorleserKit/Package.swift @@ -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: [ diff --git a/VorleserKit/Sources/AudioEngine/AudioEngine.swift b/VorleserKit/Sources/AudioEngine/AudioEngine.swift new file mode 100644 index 0000000..ae84a4a --- /dev/null +++ b/VorleserKit/Sources/AudioEngine/AudioEngine.swift @@ -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? + + 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? = { + 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 + } +} diff --git a/VorleserKit/Sources/AudioEngine/PlaybackState.swift b/VorleserKit/Sources/AudioEngine/PlaybackState.swift new file mode 100644 index 0000000..2da372b --- /dev/null +++ b/VorleserKit/Sources/AudioEngine/PlaybackState.swift @@ -0,0 +1,6 @@ +public enum PlaybackState: Sendable { + case idle + case synthesizing + case playing + case paused +}