Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion macos/Astroshots/Narration/NarrationModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions macos/Astroshots/Narration/NarrationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 5 additions & 39 deletions macos/Astroshots/Narration/NarrationVideoComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion macos/AstroshotsTests/NarrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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])
}
}
Loading