use jugar_probar::brick::{
AudioBrick, BrickWorkerMessage, BrickWorkerMessageDirection, EventBrick, EventHandler,
EventType, FieldType, WorkerBrick,
};
use std::fs;
#[derive(Debug, Clone)]
pub struct GenerateConfig {
pub app_name: String,
pub wasm_module: String,
pub model_path: Option<String>,
pub title: String,
pub output_dir: std::path::PathBuf,
}
impl Default for GenerateConfig {
fn default() -> Self {
Self {
app_name: "app".into(),
wasm_module: "./pkg/app.js".into(),
model_path: None,
title: "Probar Application".into(),
output_dir: std::path::PathBuf::from("."),
}
}
}
#[derive(Debug)]
pub struct GenerateResult {
pub html: String,
pub css: String,
pub main_js: String,
pub worker_js: Option<String>,
pub worklet_js: Option<String>,
pub files_written: Vec<std::path::PathBuf>,
}
pub fn generate_from_bricks(
worker: Option<&WorkerBrick>,
events: Option<&EventBrick>,
audio: Option<&AudioBrick>,
config: &GenerateConfig,
) -> Result<GenerateResult, String> {
let mut files_written = Vec::new();
let html = generate_html(config);
let css = generate_css();
let main_js = generate_main_js(events, config);
let worker_js = worker.map(jugar_probar::WorkerBrick::to_worker_js);
let worklet_js = audio.map(jugar_probar::AudioBrick::to_worklet_js);
let output_dir = &config.output_dir;
fs::create_dir_all(output_dir).map_err(|e| format!("Failed to create output dir: {e}"))?;
let html_path = output_dir.join("index.html");
fs::write(&html_path, &html).map_err(|e| format!("Failed to write index.html: {e}"))?;
files_written.push(html_path);
let css_path = output_dir.join("style.css");
fs::write(&css_path, &css).map_err(|e| format!("Failed to write style.css: {e}"))?;
files_written.push(css_path);
let main_js_path = output_dir.join("main.js");
fs::write(&main_js_path, &main_js).map_err(|e| format!("Failed to write main.js: {e}"))?;
files_written.push(main_js_path);
if let Some(ref wjs) = worker_js {
let worker_path = output_dir.join("worker.js");
fs::write(&worker_path, wjs).map_err(|e| format!("Failed to write worker.js: {e}"))?;
files_written.push(worker_path);
}
if let Some(ref ajs) = worklet_js {
let worklet_path = output_dir.join("audio-worklet.js");
fs::write(&worklet_path, ajs)
.map_err(|e| format!("Failed to write audio-worklet.js: {e}"))?;
files_written.push(worklet_path);
}
Ok(GenerateResult {
html,
css,
main_js,
worker_js,
worklet_js,
files_written,
})
}
fn generate_html(config: &GenerateConfig) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app" class="container">
<h1>{title}</h1>
<div id="status" class="status-brick" aria-live="polite">Loading...</div>
<div id="controls" class="controls">
<button id="record" disabled aria-label="Start/Stop Recording">Record</button>
<button id="clear" aria-label="Clear">Clear</button>
</div>
<div id="output" class="output">
<div id="partial" class="transcription-partial"></div>
<div id="transcript" class="transcription-final" aria-live="polite"></div>
</div>
</div>
<script type="module" src="main.js"></script>
</body>
</html>
"#,
title = config.title,
)
}
fn generate_css() -> String {
r"/* Generated by probar - DO NOT EDIT MANUALLY */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.container {
max-width: 800px;
width: 100%;
}
h1 {
text-align: center;
margin-bottom: 2rem;
color: #4dc3ff;
}
.status-brick {
background: #16213e;
padding: 1rem;
border-radius: 8px;
font-weight: 500;
margin-bottom: 1rem;
}
.status-loading { color: #4dc3ff; }
.status-ready { color: #50fa7b; }
.status-recording { color: #50fa7b; animation: pulse 1s infinite; }
.status-error { color: #ff6b6b; }
.controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
button {
padding: 1rem 2rem;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
#record {
background: #e94560;
color: white;
flex: 1;
}
#record:hover:not(:disabled) { background: #ff6b6b; }
#record:disabled { background: #666; cursor: not-allowed; }
#record.recording { background: #50fa7b; animation: pulse 1s infinite; }
#clear {
background: #4dc3ff;
color: #1a1a2e;
}
#clear:hover { background: #7dd5ff; }
.output {
background: #16213e;
border-radius: 8px;
padding: 1.5rem;
min-height: 200px;
}
.transcription-partial {
color: #888;
font-style: italic;
margin-bottom: 1rem;
min-height: 1.5em;
}
.transcription-final {
color: #eee;
font-size: 1.2rem;
line-height: 1.6;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
"
.into()
}
fn generate_main_js(events: Option<&EventBrick>, config: &GenerateConfig) -> String {
let mut js = String::new();
js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
js.push_str(&format!(
"import init, {{ WorkerManager }} from '{}';\n\n",
config.wasm_module
));
js.push_str("let manager = null;\n");
js.push_str("let isRecording = false;\n");
js.push_str("let audioContext = null;\n");
js.push_str("let mediaStream = null;\n\n");
js.push_str("const status = document.getElementById('status');\n");
js.push_str("const recordBtn = document.getElementById('record');\n");
js.push_str("const clearBtn = document.getElementById('clear');\n\n");
js.push_str("async function initApp() {\n");
js.push_str(" try {\n");
js.push_str(" status.textContent = 'Loading WASM...';\n");
js.push_str(" status.className = 'status-brick status-loading';\n");
js.push_str(" await init();\n\n");
if let Some(ref model_path) = config.model_path {
js.push_str(" status.textContent = 'Spawning worker...';\n");
js.push_str(" manager = new WorkerManager();\n");
js.push_str(&format!(" await manager.spawn('{model_path}');\n\n"));
js.push_str(" window.addEventListener('whisper-worker-ready', () => {\n");
js.push_str(" status.textContent = 'Loading model...';\n");
js.push_str(" manager.send_init();\n");
js.push_str(" }, { once: true });\n\n");
js.push_str(" window.addEventListener('whisper-model-loaded', (e) => {\n");
js.push_str(" const { sizeMb, loadTimeMs } = e.detail;\n");
js.push_str(" status.textContent = `Ready (${sizeMb.toFixed(1)}MB in ${(loadTimeMs/1000).toFixed(1)}s)`;\n");
js.push_str(" status.className = 'status-brick status-ready';\n");
js.push_str(" recordBtn.disabled = false;\n");
js.push_str(" });\n\n");
js.push_str(" window.addEventListener('whisper-transcription', (e) => {\n");
js.push_str(" const { text, isFinal } = e.detail;\n");
js.push_str(" if (isFinal) {\n");
js.push_str(
" document.getElementById('transcript').textContent += text + ' ';\n",
);
js.push_str(" document.getElementById('partial').textContent = '';\n");
js.push_str(" } else {\n");
js.push_str(" document.getElementById('partial').textContent = text;\n");
js.push_str(" }\n");
js.push_str(" });\n");
} else {
js.push_str(" status.textContent = 'Ready';\n");
js.push_str(" status.className = 'status-brick status-ready';\n");
js.push_str(" recordBtn.disabled = false;\n");
}
js.push_str(" } catch (err) {\n");
js.push_str(" status.textContent = 'Error: ' + err.message;\n");
js.push_str(" status.className = 'status-brick status-error';\n");
js.push_str(" console.error('[App] Init failed:', err);\n");
js.push_str(" }\n");
js.push_str("}\n\n");
if let Some(event_brick) = events {
js.push_str(&event_brick.to_event_js());
js.push('\n');
} else {
js.push_str("// Default event handlers\n");
js.push_str("recordBtn.addEventListener('click', async () => {\n");
js.push_str(" if (isRecording) {\n");
js.push_str(" if (manager) manager.stopRecording();\n");
js.push_str(" if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());\n");
js.push_str(" if (audioContext) audioContext.close();\n");
js.push_str(" isRecording = false;\n");
js.push_str(" recordBtn.textContent = 'Record';\n");
js.push_str(" recordBtn.classList.remove('recording');\n");
js.push_str(" status.textContent = 'Ready';\n");
js.push_str(" status.className = 'status-brick status-ready';\n");
js.push_str(" } else {\n");
js.push_str(" try {\n");
js.push_str(" mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n");
js.push_str(" audioContext = new AudioContext();\n");
js.push_str(" if (manager) manager.startRecording(audioContext.sampleRate);\n");
js.push_str(" isRecording = true;\n");
js.push_str(" recordBtn.textContent = 'Stop';\n");
js.push_str(" recordBtn.classList.add('recording');\n");
js.push_str(" status.textContent = 'Recording...';\n");
js.push_str(" status.className = 'status-brick status-recording';\n");
js.push_str(" } catch (err) {\n");
js.push_str(" status.textContent = 'Mic access denied';\n");
js.push_str(" status.className = 'status-brick status-error';\n");
js.push_str(" }\n");
js.push_str(" }\n");
js.push_str("});\n\n");
js.push_str("clearBtn.addEventListener('click', () => {\n");
js.push_str(" document.getElementById('transcript').textContent = '';\n");
js.push_str(" document.getElementById('partial').textContent = '';\n");
js.push_str("});\n\n");
}
js.push_str("initApp();\n");
js
}
#[must_use]
pub fn create_whisper_worker_brick() -> WorkerBrick {
WorkerBrick::new("whisper-transcription")
.message(
BrickWorkerMessage::new("bootstrap", BrickWorkerMessageDirection::ToWorker)
.field("wasmUrl", FieldType::String)
.field("modelUrl", FieldType::String),
)
.message(BrickWorkerMessage::new(
"ready",
BrickWorkerMessageDirection::FromWorker,
))
.message(
BrickWorkerMessage::new("init", BrickWorkerMessageDirection::ToWorker)
.field("ringBuffer", FieldType::SharedArrayBuffer),
)
.message(
BrickWorkerMessage::new("model_loaded", BrickWorkerMessageDirection::FromWorker)
.field("sizeMb", FieldType::Number)
.field("loadTimeMs", FieldType::Number),
)
.message(
BrickWorkerMessage::new("start", BrickWorkerMessageDirection::ToWorker)
.field("sampleRate", FieldType::Number),
)
.message(BrickWorkerMessage::new(
"stop",
BrickWorkerMessageDirection::ToWorker,
))
.message(
BrickWorkerMessage::new("transcription", BrickWorkerMessageDirection::FromWorker)
.field("text", FieldType::String)
.field("isFinal", FieldType::Boolean),
)
.state("uninitialized")
.state("bootstrapped")
.state("loading")
.state("ready")
.state("recording")
.initial("uninitialized")
.transition("uninitialized", "bootstrap", "bootstrapped")
.transition("bootstrapped", "ready", "loading")
.transition("loading", "init", "loading")
.transition("loading", "model_loaded", "ready")
.transition("ready", "start", "recording")
.transition("recording", "stop", "ready")
.transition("recording", "transcription", "recording")
}
#[must_use]
pub fn create_whisper_event_brick() -> EventBrick {
EventBrick::new()
.on(
"#record",
EventType::Click,
EventHandler::dispatch_state("toggle_recording"),
)
.on(
"#clear",
EventType::Click,
EventHandler::call_wasm("clear_transcript"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_html() {
let config = GenerateConfig {
title: "Test App".into(),
..Default::default()
};
let html = generate_html(&config);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<title>Test App</title>"));
assert!(html.contains("id=\"status\""));
assert!(html.contains("id=\"record\""));
assert!(html.contains("aria-label"));
}
#[test]
fn test_generate_css() {
let css = generate_css();
assert!(css.contains("Generated by probar"));
assert!(css.contains(".status-brick"));
assert!(css.contains(".transcription-final"));
assert!(css.contains("@keyframes pulse"));
}
#[test]
fn test_generate_main_js() {
let config = GenerateConfig {
wasm_module: "./pkg/app.js".into(),
model_path: Some("/models/test.bin".into()),
..Default::default()
};
let js = generate_main_js(None, &config);
assert!(js.contains("Generated by probar"));
assert!(js.contains("import init"));
assert!(js.contains("initApp"));
assert!(js.contains("whisper-worker-ready"));
}
#[test]
fn test_generate_from_bricks() {
let temp_dir = TempDir::new().unwrap();
let config = GenerateConfig {
app_name: "test".into(),
title: "Test".into(),
output_dir: temp_dir.path().to_path_buf(),
..Default::default()
};
let worker = create_whisper_worker_brick();
let events = create_whisper_event_brick();
let result = generate_from_bricks(Some(&worker), Some(&events), None, &config);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.files_written.len() >= 4);
assert!(result.worker_js.is_some());
assert!(temp_dir.path().join("index.html").exists());
assert!(temp_dir.path().join("style.css").exists());
assert!(temp_dir.path().join("main.js").exists());
assert!(temp_dir.path().join("worker.js").exists());
}
#[test]
fn test_create_whisper_worker_brick() {
let worker = create_whisper_worker_brick();
let js = worker.to_worker_js();
assert!(
js.contains("WhisperTranscription"),
"Missing WhisperTranscription"
);
assert!(js.contains("bootstrap"), "Missing bootstrap (ToWorker)");
assert!(js.contains("init"), "Missing init (ToWorker)");
assert!(js.contains("start"), "Missing start (ToWorker)");
assert!(js.contains("stop"), "Missing stop (ToWorker)");
let rust = worker.to_rust_bindings();
assert!(rust.contains("pub enum ToWorker"));
assert!(rust.contains("pub enum FromWorker"));
assert!(
rust.contains("ModelLoaded"),
"Missing ModelLoaded (FromWorker)"
);
assert!(
rust.contains("Transcription"),
"Missing Transcription (FromWorker)"
);
}
#[test]
fn test_generate_config_default() {
let config = GenerateConfig::default();
assert_eq!(config.app_name, "app");
assert_eq!(config.wasm_module, "./pkg/app.js");
assert!(config.model_path.is_none());
assert_eq!(config.title, "Probar Application");
assert_eq!(config.output_dir, std::path::PathBuf::from("."));
}
#[test]
fn test_generate_main_js_no_model_path() {
let config = GenerateConfig {
model_path: None,
..Default::default()
};
let js = generate_main_js(None, &config);
assert!(js.contains("status.textContent = 'Ready'"));
assert!(js.contains("status.className = 'status-brick status-ready'"));
assert!(js.contains("recordBtn.disabled = false"));
assert!(!js.contains("whisper-worker-ready"));
}
#[test]
fn test_generate_main_js_with_event_brick() {
let config = GenerateConfig::default();
let events = create_whisper_event_brick();
let js = generate_main_js(Some(&events), &config);
assert!(js.contains("#record"));
assert!(js.contains("#clear"));
assert!(!js.contains("// Default event handlers"));
}
#[test]
fn test_create_whisper_event_brick() {
let events = create_whisper_event_brick();
let js = events.to_event_js();
assert!(js.contains("#record"));
assert!(js.contains("#clear"));
assert!(js.contains("click"));
}
#[test]
fn test_generate_from_bricks_no_worker_no_audio() {
let temp_dir = TempDir::new().unwrap();
let config = GenerateConfig {
output_dir: temp_dir.path().to_path_buf(),
..Default::default()
};
let result = generate_from_bricks(None, None, None, &config);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.worker_js.is_none());
assert!(result.worklet_js.is_none());
assert_eq!(result.files_written.len(), 3); }
#[test]
fn test_generate_from_bricks_with_audio() {
use jugar_probar::brick::AudioBrick;
let temp_dir = TempDir::new().unwrap();
let config = GenerateConfig {
output_dir: temp_dir.path().to_path_buf(),
..Default::default()
};
let audio = AudioBrick::new("test-audio")
.inputs(1)
.outputs(1)
.sample_rate(16000);
let result = generate_from_bricks(None, None, Some(&audio), &config);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.worklet_js.is_some());
assert!(temp_dir.path().join("audio-worklet.js").exists());
}
#[test]
fn test_generate_from_bricks_invalid_output_dir() {
let config = GenerateConfig {
output_dir: std::path::PathBuf::from("/dev/null/impossible/path"),
..Default::default()
};
let result = generate_from_bricks(None, None, None, &config);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to create output dir"));
}
#[test]
fn test_generate_result_fields() {
let temp_dir = TempDir::new().unwrap();
let config = GenerateConfig {
title: "Test Result Fields".into(),
output_dir: temp_dir.path().to_path_buf(),
..Default::default()
};
let result = generate_from_bricks(None, None, None, &config).unwrap();
assert!(result.html.contains("Test Result Fields"));
assert!(result.css.contains("Generated by probar"));
assert!(result.main_js.contains("initApp"));
}
#[test]
fn test_generate_html_escaping() {
let config = GenerateConfig {
title: "Test & Special <chars>".into(),
..Default::default()
};
let html = generate_html(&config);
assert!(html.contains("Test & Special <chars>"));
}
#[test]
fn test_generate_main_js_imports() {
let config = GenerateConfig {
wasm_module: "./custom/path.js".into(),
..Default::default()
};
let js = generate_main_js(None, &config);
assert!(js.contains("import init, { WorkerManager } from './custom/path.js'"));
}
}