188 lines
4.8 KiB
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
|
|
}
|
|
}
|