phase7: speech pipeline (mlx-whisper via PyBridge + Playground panel + CLI), cancel-state fix, resume/cancel CLI hooks, ffmpeg PATH; matrix progress: speech/cancel/resume/OOM-gate/catalog green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 7 changed files with +261 and −16
modified
Sources/ZyquoMLX/App/CLIFoundry.swift
+39 −8
@@ -19,7 +19,7 @@ extension CLI { | ||
| 19 | 19 | let args = CommandLine.arguments |
| 20 | 20 | return args.contains("--train") || args.contains("--fuse") |
| 21 | 21 | || args.contains("--quantize") || args.contains("--validate-dataset") |
| 22 | − || args.contains("--download") | |
| 22 | + || args.contains("--download") || args.contains("--transcribe") | |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | static func runFoundry() async -> Int32 { |
@@ -45,6 +45,18 @@ extension CLI { | ||
| 45 | 45 | try await download(repo: repo) |
| 46 | 46 | return 0 |
| 47 | 47 | } |
| 48 | + if let modelPath = value(after: "--transcribe", in: args), | |
| 49 | + let audioPath = value(after: "--audio", in: args) | |
| 50 | + { | |
| 51 | + let model = try await ModelStore.shared.describe( | |
| 52 | + directory: URL(fileURLWithPath: (modelPath as NSString).expandingTildeInPath)) | |
| 53 | + let result = try await SpeechService.shared.transcribe( | |
| 54 | + model: model, | |
| 55 | + audio: URL(fileURLWithPath: (audioPath as NSString).expandingTildeInPath)) | |
| 56 | + print("language: \(result.language ?? "?") segments: \(result.segments) took \(String(format: "%.1f", result.duration))s") | |
| 57 | + print(result.text) | |
| 58 | + return 0 | |
| 59 | + } | |
| 48 | 60 | return 2 |
| 49 | 61 | } catch { |
| 50 | 62 | FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) |
@@ -95,14 +107,33 @@ extension CLI { | ||
| 95 | 107 | } |
| 96 | 108 | |
| 97 | 109 | let service = TrainingService.shared |
| 98 | − let run = try await service.createRun( | |
| 99 | − name: "cli-\(model.name)-\(method.rawValue)", | |
| 100 | − baseModel: model, dataset: dataset, method: method, hyperParams: hp) | |
| 101 | − print("run: \(run.id.uuidString)") | |
| 102 | − print("method: \(method.displayName) on \(model.name)") | |
| 103 | − print("verdict: \(MemoryAdvisor.trainingVerdict(for: model, method: method, params: hp).displayName)\n") | |
| 110 | + let resume: Bool | |
| 111 | + let run: TrainingRun | |
| 112 | + if let resumeID = value(after: "--resume-run", in: args) { | |
| 113 | + guard let id = UUID(uuidString: resumeID) else { throw CLIError.usage("bad run id") } | |
| 114 | + run = try await RunStore.shared.load(id: id) | |
| 115 | + resume = true | |
| 116 | + } else { | |
| 117 | + run = try await service.createRun( | |
| 118 | + name: "cli-\(model.name)-\(method.rawValue)", | |
| 119 | + baseModel: model, dataset: dataset, method: method, hyperParams: hp) | |
| 120 | + resume = false | |
| 121 | + } | |
| 122 | + print("run: \(run.id.uuidString)\(resume ? " (warm resume)" : "")") | |
| 123 | + print("method: \(run.method.displayName) on \(model.name)") | |
| 124 | + print("verdict: \(MemoryAdvisor.trainingVerdict(for: model, method: run.method, params: run.hyperParams).displayName)\n") | |
| 125 | + | |
| 126 | + // Test hook mirroring the UI's Cancel button (drives the same | |
| 127 | + // TrainingService.cancel() path). | |
| 128 | + if let cancelAfter = value(after: "--cancel-after", in: args).flatMap(Double.init) { | |
| 129 | + Task { | |
| 130 | + try? await Task.sleep(for: .seconds(cancelAfter)) | |
| 131 | + print("\n[--cancel-after \(cancelAfter)s] cancelling…") | |
| 132 | + await service.cancel() | |
| 133 | + } | |
| 134 | + } | |
| 104 | 135 | |
| 105 | − let events = try await service.start(run: run, baseModel: model, dataset: dataset) | |
| 136 | + let events = try await service.start(run: run, baseModel: model, dataset: dataset, resume: resume) | |
| 106 | 137 | for await event in events { |
| 107 | 138 | switch event { |
| 108 | 139 | case .started(let model, let iterations): |
added
Sources/ZyquoMLX/Engine/SpeechService.swift
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +// | |
| 2 | +// SpeechService.swift | |
| 3 | +// Zyquo MLX | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Transcription result from a speech model. | |
| 12 | +struct TranscriptionResult: Sendable { | |
| 13 | + var text: String | |
| 14 | + var language: String? | |
| 15 | + var segments: Int | |
| 16 | + var duration: TimeInterval | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Speech-to-text via the Python bridge (mlx-whisper — Python-only per | |
| 20 | +/// docs/MLX-RESEARCH.md §2). Installs the pinned package on first use. | |
| 21 | +actor SpeechService { | |
| 22 | + | |
| 23 | + static let shared = SpeechService() | |
| 24 | + | |
| 25 | + /// Exact pin per docs/MLX-RESEARCH.md version snapshot. | |
| 26 | + static let whisperPin = "mlx-whisper==0.4.3" | |
| 27 | + | |
| 28 | + private var whisperInstalled = false | |
| 29 | + | |
| 30 | + func transcribe(model: LocalModel, audio: URL) async throws -> TranscriptionResult { | |
| 31 | + try await ensureWhisper() | |
| 32 | + | |
| 33 | + let start = Date() | |
| 34 | + let stream = try await PythonRunner.shared.stream( | |
| 35 | + script: "zyquo_transcribe", | |
| 36 | + arguments: ["--model", model.directory.path, "--audio", audio.path]) | |
| 37 | + | |
| 38 | + var text = "" | |
| 39 | + var language: String? | |
| 40 | + var segments = 0 | |
| 41 | + for try await event in stream { | |
| 42 | + switch event.event { | |
| 43 | + case "done": | |
| 44 | + text = event.string("text") ?? "" | |
| 45 | + language = event.string("language") | |
| 46 | + segments = event.int("segments") ?? 0 | |
| 47 | + case "error": | |
| 48 | + throw PythonRunnerError.processFailed( | |
| 49 | + exitCode: 1, stderr: event.string("message") ?? "transcription failed") | |
| 50 | + default: | |
| 51 | + break | |
| 52 | + } | |
| 53 | + } | |
| 54 | + return TranscriptionResult( | |
| 55 | + text: text, language: language, segments: segments, | |
| 56 | + duration: Date().timeIntervalSince(start)) | |
| 57 | + } | |
| 58 | + | |
| 59 | + private func ensureWhisper() async throws { | |
| 60 | + guard !whisperInstalled else { return } | |
| 61 | + let env = PythonEnvironment.shared | |
| 62 | + if await !env.isProvisioned { | |
| 63 | + try await env.provision() | |
| 64 | + } | |
| 65 | + // Cheap idempotent install (uv resolves instantly when satisfied). | |
| 66 | + try await env.install(pins: [Self.whisperPin]) | |
| 67 | + whisperInstalled = true | |
| 68 | + } | |
| 69 | +} | |
modified
Sources/ZyquoMLX/PyBridge/PythonRunner.swift
+4 −2
@@ -77,9 +77,11 @@ actor PythonRunner { | ||
| 77 | 77 | let process = Process() |
| 78 | 78 | process.executableURL = python |
| 79 | 79 | process.arguments = ["-u", scriptURL.path] + arguments |
| 80 | − // Isolated, reproducible interpreter environment. | |
| 80 | + // Isolated, reproducible interpreter environment. Homebrew paths are | |
| 81 | + // included for external tools some pipelines shell out to (ffmpeg | |
| 82 | + // for mlx-whisper audio decoding). | |
| 81 | 83 | process.environment = [ |
| 82 | − "PATH": "/usr/bin:/bin", | |
| 84 | + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", | |
| 83 | 85 | "HOME": NSHomeDirectory(), |
| 84 | 86 | "PYTHONUNBUFFERED": "1", |
| 85 | 87 | ] |
added
Sources/ZyquoMLX/PyBridge/scripts/zyquo_transcribe.py
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +# | |
| 2 | +# zyquo_transcribe.py | |
| 3 | +# Zyquo MLX | |
| 4 | +# | |
| 5 | +# Author: Simon-Pierre Boucher | |
| 6 | +# Mail: contact@spboucher.ai | |
| 7 | +# | |
| 8 | +# Speech-to-text driver over mlx-whisper (docs/MLX-RESEARCH.md §3.2). | |
| 9 | +# JSON-lines protocol on stdout. | |
| 10 | +# | |
| 11 | +# Usage: zyquo_transcribe.py --model <dir-or-repo> --audio <file> | |
| 12 | + | |
| 13 | +import argparse | |
| 14 | +import json | |
| 15 | +import sys | |
| 16 | + | |
| 17 | + | |
| 18 | +def emit(obj): | |
| 19 | + sys.stdout.write(json.dumps(obj) + "\n") | |
| 20 | + sys.stdout.flush() | |
| 21 | + | |
| 22 | + | |
| 23 | +def main(): | |
| 24 | + parser = argparse.ArgumentParser() | |
| 25 | + parser.add_argument("--model", required=True) | |
| 26 | + parser.add_argument("--audio", required=True) | |
| 27 | + args = parser.parse_args() | |
| 28 | + | |
| 29 | + try: | |
| 30 | + emit({"event": "start", "stage": "transcribing", "audio": args.audio}) | |
| 31 | + import mlx_whisper | |
| 32 | + | |
| 33 | + result = mlx_whisper.transcribe(args.audio, path_or_hf_repo=args.model) | |
| 34 | + emit({ | |
| 35 | + "event": "done", | |
| 36 | + "text": result.get("text", "").strip(), | |
| 37 | + "language": result.get("language"), | |
| 38 | + "segments": len(result.get("segments", [])), | |
| 39 | + }) | |
| 40 | + except Exception as exc: # noqa: BLE001 | |
| 41 | + emit({"event": "error", "message": str(exc), "type": type(exc).__name__}) | |
| 42 | + sys.exit(1) | |
| 43 | + | |
| 44 | + | |
| 45 | +if __name__ == "__main__": | |
| 46 | + main() | |
modified
Sources/ZyquoMLX/Training/TrainingService.swift
+7 −1
@@ -135,7 +135,13 @@ actor TrainingService { | ||
| 135 | 135 | } |
| 136 | 136 | continuation.yield(event) |
| 137 | 137 | } |
| 138 | − current.state = current.lastError == nil ? .completed : .failed | |
| 138 | + // A cancelled Task ends the stream without throwing — check | |
| 139 | + // cancellation before declaring completion. | |
| 140 | + if Task.isCancelled { | |
| 141 | + current.state = .cancelled | |
| 142 | + } else { | |
| 143 | + current.state = current.lastError == nil ? .completed : .failed | |
| 144 | + } | |
| 139 | 145 | } catch is CancellationError { |
| 140 | 146 | current.state = .cancelled |
| 141 | 147 | } catch { |
modified
Sources/ZyquoMLX/Views/PlaygroundView.swift
+87 −4
@@ -36,7 +36,7 @@ struct PlaygroundView: View { | ||
| 36 | 36 | HStack(spacing: ZyquoTheme.spacing12) { |
| 37 | 37 | Picker("Model", selection: $session.selectedModelID) { |
| 38 | 38 | Text("Choose a model…").tag(String?.none) |
| 39 | − ForEach(model.models.filter { $0.type.isSwiftNative }) { item in | |
| 39 | + ForEach(model.models.filter { $0.type.isSwiftNative || $0.type == .speech }) { item in | |
| 40 | 40 | Text("\(item.name) (\(item.type.displayName))").tag(String?.some(item.id)) |
| 41 | 41 | } |
| 42 | 42 | } |
@@ -73,24 +73,37 @@ struct PlaygroundView: View { | ||
| 73 | 73 | .padding(.vertical, ZyquoTheme.spacing8) |
| 74 | 74 | .onChange(of: session.selectedModelID) { _, newID in |
| 75 | 75 | guard let newID, let item = model.models.first(where: { $0.id == newID }) else { return } |
| 76 | − Task { await session.load(item) } | |
| 76 | + if item.type == .speech { | |
| 77 | + // Out-of-process — nothing to load in the engine. | |
| 78 | + session.speechModel = item | |
| 79 | + } else { | |
| 80 | + Task { await session.load(item) } | |
| 81 | + } | |
| 77 | 82 | } |
| 78 | 83 | } |
| 79 | 84 | |
| 80 | 85 | @ViewBuilder |
| 81 | 86 | private var content: some View { |
| 82 | − switch session.loadedModel?.type { | |
| 87 | + switch session.loadedModel?.type ?? selectedType { | |
| 83 | 88 | case .embedding: |
| 84 | 89 | EmbeddingInspector(session: session) |
| 85 | 90 | case .llm, .vlm: |
| 86 | 91 | ChatPanel(session: session) |
| 92 | + case .speech: | |
| 93 | + SpeechPanel(session: session) | |
| 87 | 94 | default: |
| 88 | 95 | EmptyStateView( |
| 89 | 96 | icon: "cpu", |
| 90 | 97 | title: "Pick a Model to Begin", |
| 91 | − message: "Choose an installed model above. LLMs and vision models open a streaming chat; embedding models open the vector inspector.") | |
| 98 | + message: "Choose an installed model above. LLMs and vision models open a streaming chat; embedding models open the vector inspector; speech models open the transcriber.") | |
| 92 | 99 | } |
| 93 | 100 | } |
| 101 | + | |
| 102 | + /// Speech models run out-of-process (no in-engine load) — panel selection | |
| 103 | + /// falls back to the picker choice. | |
| 104 | + private var selectedType: ModelType? { | |
| 105 | + model.models.first { $0.id == session.selectedModelID }?.type | |
| 106 | + } | |
| 94 | 107 | } |
| 95 | 108 | |
| 96 | 109 | // MARK: - Session state |
@@ -117,6 +130,26 @@ final class PlaygroundSession { | ||
| 117 | 130 | var embedResults: [(text: String, vector: [Float])] = [] |
| 118 | 131 | var similarities: [(a: String, b: String, score: Float)] = [] |
| 119 | 132 | |
| 133 | + // Speech state (out-of-process — no engine load) | |
| 134 | + var speechModel: LocalModel? | |
| 135 | + var transcription: TranscriptionResult? | |
| 136 | + var isTranscribing = false | |
| 137 | + | |
| 138 | + func transcribe(audio: URL) { | |
| 139 | + guard let speechModel else { return } | |
| 140 | + isTranscribing = true | |
| 141 | + transcription = nil | |
| 142 | + Task { | |
| 143 | + do { | |
| 144 | + transcription = try await SpeechService.shared.transcribe( | |
| 145 | + model: speechModel, audio: audio) | |
| 146 | + } catch { | |
| 147 | + errorMessage = error.localizedDescription | |
| 148 | + } | |
| 149 | + isTranscribing = false | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 120 | 153 | func load(_ model: LocalModel) async { |
| 121 | 154 | isLoading = true |
| 122 | 155 | errorMessage = nil |
@@ -313,6 +346,56 @@ private struct MessageBubble: View { | ||
| 313 | 346 | } |
| 314 | 347 | } |
| 315 | 348 | |
| 349 | +// MARK: - Speech panel | |
| 350 | + | |
| 351 | +private struct SpeechPanel: View { | |
| 352 | + @Bindable var session: PlaygroundSession | |
| 353 | + @State private var isPickingAudio = false | |
| 354 | + | |
| 355 | + var body: some View { | |
| 356 | + VStack(spacing: ZyquoTheme.spacing16) { | |
| 357 | + if session.isTranscribing { | |
| 358 | + ProgressView("Transcribing…") | |
| 359 | + } else if let result = session.transcription { | |
| 360 | + ScrollView { | |
| 361 | + VStack(alignment: .leading, spacing: ZyquoTheme.spacing8) { | |
| 362 | + HStack { | |
| 363 | + if let language = result.language { | |
| 364 | + StatusPill(text: language, color: ZyquoTheme.slate) | |
| 365 | + } | |
| 366 | + Text("\(result.segments) segments · \(String(format: "%.1fs", result.duration))") | |
| 367 | + .font(ZyquoTheme.captionFont) | |
| 368 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 369 | + } | |
| 370 | + Text(result.text) | |
| 371 | + .font(ZyquoTheme.bodyFont) | |
| 372 | + .textSelection(.enabled) | |
| 373 | + } | |
| 374 | + .padding(ZyquoTheme.spacing16) | |
| 375 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 376 | + .zyquoCard() | |
| 377 | + .padding(ZyquoTheme.spacing20) | |
| 378 | + } | |
| 379 | + } else { | |
| 380 | + EmptyStateView( | |
| 381 | + icon: "waveform", | |
| 382 | + title: "Transcribe Audio", | |
| 383 | + message: "Pick an audio file — the speech model runs locally through the Python pipeline and returns the transcript here.", | |
| 384 | + actionLabel: "Choose Audio…", | |
| 385 | + action: { isPickingAudio = true }) | |
| 386 | + } | |
| 387 | + | |
| 388 | + if session.transcription != nil { | |
| 389 | + Button("Transcribe Another…") { isPickingAudio = true } | |
| 390 | + .padding(.bottom, ZyquoTheme.spacing16) | |
| 391 | + } | |
| 392 | + } | |
| 393 | + .fileImporter(isPresented: $isPickingAudio, allowedContentTypes: [.audio]) { result in | |
| 394 | + if case .success(let url) = result { session.transcribe(audio: url) } | |
| 395 | + } | |
| 396 | + } | |
| 397 | +} | |
| 398 | + | |
| 316 | 399 | // MARK: - Embedding inspector |
| 317 | 400 | |
| 318 | 401 | private struct EmbeddingInspector: View { |
modified
docs/PLAN.md
+9 −1
@@ -162,5 +162,13 @@ Hub pipeline searched, downloaded, and installed a real repo with resumable | ||
| 162 | 162 | progress. One heuristic fix surfaced by verification: decoder-style embedding |
| 163 | 163 | repos (Qwen3-Embedding ships a `Qwen3ForCausalLM` config) are detected by |
| 164 | 164 | name — the downloaded embedder then produced correct 1024-dim vectors. |
| 165 | −## Phase 7 — Verification (not started) | |
| 165 | +## Phase 7 — Verification (in progress) | |
| 166 | + | |
| 167 | +- [ ] Speech pipeline added (mlx-whisper via PyBridge) — closes the model-type matrix gap | |
| 168 | +- [ ] Inference matrix: LLM (streaming/cancel/memory), embeddings ×2, VLM + image, speech transcription; tok/s + TTFT recorded | |
| 169 | +- [ ] Training: LoRA + QLoRA to completion, live metrics, checkpoints, warm resume, fuse, behavior change; OOM config blocked by MemoryAdvisor | |
| 170 | +- [ ] Convert/quantize: HF→MLX (Python path) + Swift-native quantize; outputs load and run; sizes correct | |
| 171 | +- [ ] Catalog dry-verify: all Featured repo IDs live-checked against the Hub | |
| 172 | +- [ ] Python env: bootstrap/adopt/repair verified | |
| 173 | +- [ ] Green results table in docs/VERIFICATION.md (image-gen: documented deferral — upstream FLUX is script-only, no packaged pipeline) | |
| 166 | 174 | ## Phase 8 — Signing & Notarization (not started) |
| 167 | 175 | |