// tts.mbt - Text-to-Speech using Azure Cognitive Services
///|
/// Task for concurrent audio generation
priv struct AudioGenTask {
slide : SlideInfo
audio_file : String
meta_file : String
ssml_file : String
}
///|
/// Result of audio generation
priv struct AudioGenResult {
slide_id : String
duration : Double
}
///|
/// Generate audio files from SSML using Azure TTS
///
/// Workflow:
/// 1. Read manifest.json to get list of slides with SSML
/// 2. For each slide with audio:
/// - Check if audio already exists and hasn't changed (via hash)
/// - Read SSML file
/// - Wrap SSML in proper XML document
/// - Call Azure TTS REST API
/// - Save WAV file
/// - Calculate duration from WAV header
/// - Save metadata (duration + hash)
pub async fn generate_audio(output_dir : String, options : Options) -> Bool {
println("Generating audio...")
// 1. Get environment variables
let env_vars = @env.get_env_vars()
guard env_vars.get("TTS_KEY") is Some(tts_key) else {
println(" [ERROR] TTS_KEY environment variable not set")
println(" Please set TTS_KEY and TTS_REGION:")
println(" export TTS_KEY=\"your-azure-subscription-key\"")
println(" export TTS_REGION=\"eastus\"")
return false
}
guard env_vars.get("TTS_REGION") is Some(tts_region) else {
println(" [ERROR] TTS_REGION environment variable not set")
return false
}
// 2. Parse manifest.json
let manifest_path = manifest_path(output_dir)
let manifest_json = @fs.read_file(manifest_path).text()
let manifest = parse_manifest_full(manifest_json)
// 3. Prepare tasks for slides that need audio generation
let tasks : Array[AudioGenTask] = []
let mut skipped = 0
for slide in manifest.slides {
if !slide.has_audio {
println(" [ ] slide-\{slide.id}: Skip (no audio)")
continue
}
// Check if audio exists and unchanged
let meta_file = slide_meta_path(output_dir, slide.id)
if !options.force_audio && should_skip(meta_file, slide.ssml_hash) {
println(" [+] slide-\{slide.id}: Skip (exists and unchanged)")
skipped = skipped + 1
continue
}
// Add to task list
let audio_file = slide_audio_path(output_dir, slide.id)
let ssml_file = slide_ssml_path(output_dir, slide.id)
tasks.push({ slide, audio_file, meta_file, ssml_file, })
}
// 4. Prepare tasks for videos that need audio generation
// Group videos by slide to track indices
let videos_by_slide : Map[String, Array[(Int, VideoSegment)]] = Map([])
for video in manifest.videos {
if !video.has_audio {
continue
}
let slide_videos = videos_by_slide.get_or_default(video.after_slide, [])
slide_videos.push((slide_videos.length() + 1, video)) // 1-based index
videos_by_slide[video.after_slide] = slide_videos
}
for entry in videos_by_slide {
let slide_id = entry.0
let indexed_videos = entry.1
for indexed_video in indexed_videos {
let video_index = indexed_video.0
let video = indexed_video.1
// Check if audio exists and unchanged
let meta_file = video_meta_path_indexed(output_dir, slide_id, video_index)
if !options.force_audio && should_skip(meta_file, video.ssml_hash) {
println(
" [+] video-after-\{slide_id}-\{video_index}: Skip (exists and unchanged)",
)
skipped = skipped + 1
continue
}
// Add to task list - convert VideoSegment to SlideInfo for compatibility
let video_as_slide : SlideInfo = {
id: "video-after-\{slide_id}-\{video_index}",
ssml_hash: video.ssml_hash,
has_audio: video.has_audio,
}
let audio_file = video_audio_path_indexed(
output_dir, slide_id, video_index,
)
let ssml_file = video_ssml_path_indexed(output_dir, slide_id, video_index)
tasks.push({ slide: video_as_slide, audio_file, meta_file, ssml_file, })
}
}
// 5. Generate audio concurrently using task group
if tasks.length() == 0 {
println("Audio generation complete: 0 generated, \{skipped} skipped")
return true
}
let results : Array[Result[AudioGenResult, String]] = []
@async.with_task_group(fn(tg) {
for task in tasks {
tg.spawn_bg(async fn() {
let result = generate_single_audio(
task.slide.id,
task.slide.ssml_hash,
task.audio_file,
task.meta_file,
task.ssml_file,
options.voice,
tts_key,
tts_region,
)
results.push(result)
})
}
})
// 5. Report results (after all tasks complete)
let mut generated = 0
let mut failed = 0
for result in results {
match result {
Ok(audio_result) => {
println(
" [+] slide-\{audio_result.slide_id}: Generated (\{audio_result.duration}s)",
)
generated = generated + 1
}
Err(error) => {
println(" [ERROR] \{error}")
failed = failed + 1
}
}
}
println(
"Audio generation complete: \{generated} generated, \{skipped} skipped, \{failed} failed",
)
// Return false if any generation failed
failed == 0
}
///|
/// Generate audio for a single slide
async fn generate_single_audio(
slide_id : String,
ssml_hash : String,
audio_file : String,
meta_file : String,
ssml_file : String,
voice : String,
tts_key : String,
tts_region : String,
) -> Result[AudioGenResult, String] {
println(" [*] slide-\{slide_id}: Generating audio...")
let ssml_content = @fs.read_file(ssml_file).text()
let full_ssml = wrap_ssml(ssml_content, voice)
// Call Azure TTS API
match synthesize_speech_simple(full_ssml, audio_file, tts_key, tts_region) {
Ok(duration) => {
// Save metadata
let meta : AudioMeta = { duration, hash: ssml_hash, }
write_file(meta_file, Json::stringify(meta.to_json()))
Ok({ slide_id, duration, })
}
Err(error) => Err("slide-\{slide_id}: Failed - \{error}")
}
}
///|
/// Wrap SSML content into complete SSML document
fn wrap_ssml(content : String, voice : String) -> String {
"\n \n \n \{content}\n
\n \n "
}
///|
/// Check if audio should be skipped (exists and hash matches)
async fn should_skip(meta_file : String, current_hash : String) -> Bool {
if !@fs.exists(meta_file) {
return false
}
let meta_json = @fs.read_file(meta_file).text()
let meta : AudioMeta = (@json.parse(meta_json) |> @json.from_json) catch {
_ => return false
}
meta.hash == current_hash
}
///|
/// Synthesize speech (no retry)
async fn synthesize_speech_simple(
ssml : String,
output_file : String,
tts_key : String,
tts_region : String,
) -> Result[Double, String] {
// Connect to Azure TTS endpoint
let host = "\{tts_region}.tts.speech.microsoft.com"
let headers : Map[@http.CaseInsensitiveString, String] = {
"Ocp-Apim-Subscription-Key": tts_key,
"X-Microsoft-OutputFormat": "riff-24khz-16bit-mono-pcm",
"User-Agent": "mdcourse-moonbit/0.1.0",
}
let client = @http.Client("https://\{host}", headers~)
// POST SSML to Azure TTS API
let extra_headers : Map[@http.CaseInsensitiveString, String] = {
"Content-Type": "application/ssml+xml",
}
let response = client.post(
"/cognitiveservices/v1",
ssml, // String implements Data trait
extra_headers~,
)
// Check response status
if response.code != 200 {
client.close()
return Err("TTS API error: HTTP \{response.code} - \{response.reason}")
}
// Read binary audio data
let audio_data = client.read_all()
let audio_bytes = audio_data.binary()
client.close()
// Save WAV file
@fs.write_file(output_file, audio_bytes, permission=0o644)
// Calculate duration from WAV header
let duration = get_wav_duration(audio_bytes)
Ok(duration)
}
///|
/// Parse manifest.json to extract slide and video info
fn parse_manifest_full(json_str : String) -> Manifest {
(@json.parse(json_str) |> @json.from_json) catch {
e => abort("JSON parse error: \{e}")
}
}
///|
extend AudioMeta with ToJson::{to_json}