add ReaderViewModel: observable view model wrapping AudioEngine, chapter offsets, synthesizer init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
76
VorleserKit/Sources/AudioEngine/ReaderViewModel.swift
Normal file
76
VorleserKit/Sources/AudioEngine/ReaderViewModel.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user