From eeaf722ebae66c3d18b66ec08da5bd2df51713b7 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 12 Aug 2026 16:54:27 -0700 Subject: [PATCH] Stabilize narration voice and simplify bookends --- .../Narration/NarrationModelManager.swift | 5 ++- .../Narration/NarrationModels.swift | 2 + .../Narration/NarrationVideoComposer.swift | 44 +++---------------- macos/AstroshotsTests/NarrationTests.swift | 20 ++++++++- 4 files changed, 30 insertions(+), 41 deletions(-) diff --git a/macos/Astroshots/Narration/NarrationModelManager.swift b/macos/Astroshots/Narration/NarrationModelManager.swift index e3c78cc..42724fa 100644 --- a/macos/Astroshots/Narration/NarrationModelManager.swift +++ b/macos/Astroshots/Narration/NarrationModelManager.swift @@ -305,12 +305,15 @@ final class NarrationSpeechBox: @unchecked Sendable { func speak(_ text: String, voice: String = NarrationDefaults.voice) async throws -> [Float] { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + var generationParameters = model.defaultGenerationParameters + generationParameters.temperature = NarrationDefaults.temperature let audio: MLXArray = try await model.generate( text: trimmed, voice: NarrationVoice.normalized(voice), refAudio: nil, refText: nil, - language: NarrationDefaults.language + language: NarrationDefaults.language, + generationParameters: generationParameters ) return audio.asArray(Float.self) } diff --git a/macos/Astroshots/Narration/NarrationModels.swift b/macos/Astroshots/Narration/NarrationModels.swift index 14c49de..aa6685e 100644 --- a/macos/Astroshots/Narration/NarrationModels.swift +++ b/macos/Astroshots/Narration/NarrationModels.swift @@ -6,6 +6,8 @@ enum NarrationDefaults { /// CustomVoice English preset (see Qwen3-TTS README). static let voice = "Ryan" static let language = "English" + /// Keep independently synthesized friction-log steps sounding like one narrator. + static let temperature: Float = 0.3 /// Minimum step display duration when audio is shorter. static let minimumStepSeconds: Double = 1.6 static let framesPerSecond: Int32 = 12 diff --git a/macos/Astroshots/Narration/NarrationVideoComposer.swift b/macos/Astroshots/Narration/NarrationVideoComposer.swift index ba348f7..be30e1d 100644 --- a/macos/Astroshots/Narration/NarrationVideoComposer.swift +++ b/macos/Astroshots/Narration/NarrationVideoComposer.swift @@ -143,9 +143,7 @@ enum NarrationVideoComposer { } writer.startSession(atSourceTime: .zero) - let image = step.isBrandCard - ? (brandBackground() ?? placeholderImage(size: size)) - : (loadImage(path: step.imagePath) ?? placeholderImage(size: size)) + let image = loadImage(path: step.imagePath) ?? placeholderImage(size: size) // Still-image segments stay inexpensive while fades remain visibly smooth. let fps = NarrationDefaults.framesPerSecond let frameCount = max(2, Int(ceil(duration * Double(fps)))) @@ -434,11 +432,13 @@ enum NarrationVideoComposer { | CGBitmapInfo.byteOrder32Little.rawValue ) else { return nil } - context.setFillColor(NSColor(calibratedWhite: 0.035, alpha: 1).cgColor) + let backgroundColor = step.isBrandCard + ? NSColor.black + : NSColor(calibratedWhite: 0.035, alpha: 1) + context.setFillColor(backgroundColor.cgColor) context.fill(CGRect(origin: .zero, size: size)) if step.isBrandCard { - drawAspectFill(image, in: CGRect(origin: .zero, size: size), context: context) drawBrandOverlay(title: step.title, size: size, context: context) return buffer } @@ -512,14 +512,6 @@ enum NarrationVideoComposer { return chunks[min(Int(progress * Double(chunks.count)), chunks.count - 1)] } - private static func brandBackground() -> NSImage? { - guard let url = Bundle.main.url( - forResource: "astroshots-dmg-background", - withExtension: "png" - ) else { return nil } - return NSImage(contentsOf: url) - } - private static func brandIcon() -> NSImage? { guard let url = Bundle.main.url( forResource: "astroshots-app-icon-master", @@ -549,32 +541,6 @@ enum NarrationVideoComposer { context.draw(cgImage, in: rect) } - private static func drawAspectFill( - _ image: NSImage, - in frame: CGRect, - context: CGContext - ) { - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return - } - let imageSize = CGSize(width: cgImage.width, height: cgImage.height) - let scale = max(frame.width / imageSize.width, frame.height / imageSize.height) - let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) - context.saveGState() - context.clip(to: frame) - context.interpolationQuality = .high - context.draw( - cgImage, - in: CGRect( - x: frame.midX - drawSize.width / 2, - y: frame.midY - drawSize.height / 2, - width: drawSize.width, - height: drawSize.height - ) - ) - context.restoreGState() - } - private static func withFlippedAppKitContext( _ context: CGContext, draw: () -> Void diff --git a/macos/AstroshotsTests/NarrationTests.swift b/macos/AstroshotsTests/NarrationTests.swift index 570abfa..3628f88 100644 --- a/macos/AstroshotsTests/NarrationTests.swift +++ b/macos/AstroshotsTests/NarrationTests.swift @@ -62,6 +62,7 @@ struct NarrationTests { @Test func defaultModelIsQwen3TTS() { #expect(NarrationDefaults.modelID.contains("Qwen3-TTS")) #expect(NarrationDefaults.voice == "Ryan") + #expect(NarrationDefaults.temperature == 0.3) #expect(NarrationVoice.available.map(\.id).contains("Aiden")) } @@ -129,7 +130,11 @@ struct NarrationTests { kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), ] ) - #expect(CMSampleBufferGetImageBuffer(videoSample) != nil) + let titleFrame = try #require(CMSampleBufferGetImageBuffer(videoSample)) + let titleCorner = try pixelRGB(in: titleFrame, x: 8, y: 8) + #expect(titleCorner.red < 16) + #expect(titleCorner.green < 16) + #expect(titleCorner.blue < 16) let audioSample = try decodeFirstSample( from: asset, @@ -311,4 +316,17 @@ struct NarrationTests { } return sample } + + private func pixelRGB( + in buffer: CVPixelBuffer, + x: Int, + y: Int + ) throws -> (red: UInt8, green: UInt8, blue: UInt8) { + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + let base = try #require(CVPixelBufferGetBaseAddress(buffer)) + let offset = y * CVPixelBufferGetBytesPerRow(buffer) + x * 4 + let pixel = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + return (red: pixel[2], green: pixel[1], blue: pixel[0]) + } }