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

77 lines
2.3 KiB
Swift

import Foundation
import Observation
import BookParser
import VorleserKit
import class Synthesizer.Synthesizer
import struct Synthesizer.VoicePack
@Observable
@MainActor
public final class ReaderViewModel {
public private(set) var book: Book?
public private(set) var error: String?
public private(set) var synthesizer: Synthesizer?
public var selectedChapterIndex: Int = 0
public let engine = AudioEngine()
/// Character offset in the full book text where each chapter starts.
public private(set) var chapterOffsets: [Int] = []
public init() {}
public func loadBook(fileURL: URL, fileExists: Bool, lastPosition: Int, modelURL: URL?, voicesURL: URL?) async {
guard fileExists else {
self.error = "Book file is missing. Please re-import."
return
}
do {
let parsed = try BookParser.parse(url: fileURL)
self.book = parsed
// Build chapter offset table
var offsets: [Int] = []
var offset = 0
for chapter in parsed.chapters {
offsets.append(offset)
offset += chapter.text.count
}
self.chapterOffsets = offsets
// Restore chapter position
if lastPosition > 0,
let (chIdx, _) = parsed.chapterAndLocalOffset(for: lastPosition) {
selectedChapterIndex = chIdx
}
// Init synthesizer
guard let modelURL, let voicesURL else {
error = "TTS model files not found in app bundle."
return
}
let voice = VoicePack.curated.first!
self.synthesizer = try Synthesizer(voice: voice, modelURL: modelURL, voicesURL: voicesURL)
} catch {
self.error = "Failed to load book: \(error)"
}
}
public func startPlayback(from offset: CharacterOffset) async throws {
guard let book, let synthesizer else { return }
try await engine.play(book: book, from: offset, using: synthesizer)
}
/// Returns the global character offset for a chapter index.
public func chapterOffset(for chapterIndex: Int) -> CharacterOffset {
guard chapterIndex < chapterOffsets.count else { return 0 }
return chapterOffsets[chapterIndex]
}
/// Returns the current sentence range in global character offsets, or nil if not playing.
public func currentSentenceRange() -> Range<CharacterOffset>? {
guard engine.state == .playing || engine.state == .synthesizing,
let book else { return nil }
guard let idx = book.sentenceIndex(containing: engine.currentPosition) else { return nil }
return book.sentences[idx].range
}
}