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