Files
vorleser/VorleserKit/Sources/AudioEngine/AudioEngine.swift

188 lines
4.8 KiB
Swift

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: [Float] = try autoreleasepool {
try synthesizer.synthesize(text: sentence.text)
}
synthesizer.clearCache()
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: [Float] = try? autoreleasepool(invoking: {
try synthesizer.synthesize(text: nextText)
}) else { return nil }
synthesizer.clearCache()
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
}
}