From b91bfcce2f2a3146a96c4cbc4f07d1db3d1785bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:50:04 +0100 Subject: [PATCH] add ReaderViewModel: observable view model wrapping AudioEngine, chapter offsets, synthesizer init Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/AudioEngine/ReaderViewModel.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 VorleserKit/Sources/AudioEngine/ReaderViewModel.swift diff --git a/VorleserKit/Sources/AudioEngine/ReaderViewModel.swift b/VorleserKit/Sources/AudioEngine/ReaderViewModel.swift new file mode 100644 index 0000000..68ec2af --- /dev/null +++ b/VorleserKit/Sources/AudioEngine/ReaderViewModel.swift @@ -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? { + 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 + } +}