1use jugar_probar::brick::{
7 AudioBrick, BrickWorkerMessage, BrickWorkerMessageDirection, EventBrick, EventHandler,
8 EventType, FieldType, WorkerBrick,
9};
10use std::fs;
11
12#[derive(Debug, Clone)]
14pub struct GenerateConfig {
15 pub app_name: String,
17 pub wasm_module: String,
19 pub model_path: Option<String>,
21 pub title: String,
23 pub output_dir: std::path::PathBuf,
25}
26
27impl Default for GenerateConfig {
28 fn default() -> Self {
29 Self {
30 app_name: "app".into(),
31 wasm_module: "./pkg/app.js".into(),
32 model_path: None,
33 title: "Probar Application".into(),
34 output_dir: std::path::PathBuf::from("."),
35 }
36 }
37}
38
39#[derive(Debug)]
41pub struct GenerateResult {
42 pub html: String,
44 pub css: String,
46 pub main_js: String,
48 pub worker_js: Option<String>,
50 pub worklet_js: Option<String>,
52 pub files_written: Vec<std::path::PathBuf>,
54}
55
56pub fn generate_from_bricks(
58 worker: Option<&WorkerBrick>,
59 events: Option<&EventBrick>,
60 audio: Option<&AudioBrick>,
61 config: &GenerateConfig,
62) -> Result<GenerateResult, String> {
63 let mut files_written = Vec::new();
64
65 let html = generate_html(config);
67
68 let css = generate_css();
70
71 let main_js = generate_main_js(events, config);
73
74 let worker_js = worker.map(jugar_probar::WorkerBrick::to_worker_js);
76
77 let worklet_js = audio.map(jugar_probar::AudioBrick::to_worklet_js);
79
80 let output_dir = &config.output_dir;
82 fs::create_dir_all(output_dir).map_err(|e| format!("Failed to create output dir: {e}"))?;
83
84 let html_path = output_dir.join("index.html");
86 fs::write(&html_path, &html).map_err(|e| format!("Failed to write index.html: {e}"))?;
87 files_written.push(html_path);
88
89 let css_path = output_dir.join("style.css");
91 fs::write(&css_path, &css).map_err(|e| format!("Failed to write style.css: {e}"))?;
92 files_written.push(css_path);
93
94 let main_js_path = output_dir.join("main.js");
96 fs::write(&main_js_path, &main_js).map_err(|e| format!("Failed to write main.js: {e}"))?;
97 files_written.push(main_js_path);
98
99 if let Some(ref wjs) = worker_js {
101 let worker_path = output_dir.join("worker.js");
102 fs::write(&worker_path, wjs).map_err(|e| format!("Failed to write worker.js: {e}"))?;
103 files_written.push(worker_path);
104 }
105
106 if let Some(ref ajs) = worklet_js {
108 let worklet_path = output_dir.join("audio-worklet.js");
109 fs::write(&worklet_path, ajs)
110 .map_err(|e| format!("Failed to write audio-worklet.js: {e}"))?;
111 files_written.push(worklet_path);
112 }
113
114 Ok(GenerateResult {
115 html,
116 css,
117 main_js,
118 worker_js,
119 worklet_js,
120 files_written,
121 })
122}
123
124fn generate_html(config: &GenerateConfig) -> String {
126 format!(
127 r#"<!DOCTYPE html>
128<html lang="en">
129<head>
130 <meta charset="UTF-8">
131 <meta name="viewport" content="width=device-width, initial-scale=1.0">
132 <title>{title}</title>
133 <link rel="stylesheet" href="style.css">
134</head>
135<body>
136 <div id="app" class="container">
137 <h1>{title}</h1>
138 <div id="status" class="status-brick" aria-live="polite">Loading...</div>
139 <div id="controls" class="controls">
140 <button id="record" disabled aria-label="Start/Stop Recording">Record</button>
141 <button id="clear" aria-label="Clear">Clear</button>
142 </div>
143 <div id="output" class="output">
144 <div id="partial" class="transcription-partial"></div>
145 <div id="transcript" class="transcription-final" aria-live="polite"></div>
146 </div>
147 </div>
148 <script type="module" src="main.js"></script>
149</body>
150</html>
151"#,
152 title = config.title,
153 )
154}
155
156fn generate_css() -> String {
158 r"/* Generated by probar - DO NOT EDIT MANUALLY */
159
160* {
161 box-sizing: border-box;
162 margin: 0;
163 padding: 0;
164}
165
166body {
167 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
168 background: #1a1a2e;
169 color: #eee;
170 min-height: 100vh;
171 display: flex;
172 flex-direction: column;
173 align-items: center;
174 padding: 2rem;
175}
176
177.container {
178 max-width: 800px;
179 width: 100%;
180}
181
182h1 {
183 text-align: center;
184 margin-bottom: 2rem;
185 color: #4dc3ff;
186}
187
188.status-brick {
189 background: #16213e;
190 padding: 1rem;
191 border-radius: 8px;
192 font-weight: 500;
193 margin-bottom: 1rem;
194}
195
196.status-loading { color: #4dc3ff; }
197.status-ready { color: #50fa7b; }
198.status-recording { color: #50fa7b; animation: pulse 1s infinite; }
199.status-error { color: #ff6b6b; }
200
201.controls {
202 display: flex;
203 gap: 1rem;
204 margin-bottom: 1rem;
205}
206
207button {
208 padding: 1rem 2rem;
209 font-size: 1rem;
210 border: none;
211 border-radius: 8px;
212 cursor: pointer;
213 transition: all 0.2s;
214}
215
216#record {
217 background: #e94560;
218 color: white;
219 flex: 1;
220}
221
222#record:hover:not(:disabled) { background: #ff6b6b; }
223#record:disabled { background: #666; cursor: not-allowed; }
224#record.recording { background: #50fa7b; animation: pulse 1s infinite; }
225
226#clear {
227 background: #4dc3ff;
228 color: #1a1a2e;
229}
230
231#clear:hover { background: #7dd5ff; }
232
233.output {
234 background: #16213e;
235 border-radius: 8px;
236 padding: 1.5rem;
237 min-height: 200px;
238}
239
240.transcription-partial {
241 color: #888;
242 font-style: italic;
243 margin-bottom: 1rem;
244 min-height: 1.5em;
245}
246
247.transcription-final {
248 color: #eee;
249 font-size: 1.2rem;
250 line-height: 1.6;
251}
252
253@keyframes pulse {
254 0%, 100% { opacity: 1; }
255 50% { opacity: 0.7; }
256}
257"
258 .into()
259}
260
261fn generate_main_js(events: Option<&EventBrick>, config: &GenerateConfig) -> String {
263 let mut js = String::new();
264
265 js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
266
267 js.push_str(&format!(
269 "import init, {{ WorkerManager }} from '{}';\n\n",
270 config.wasm_module
271 ));
272
273 js.push_str("let manager = null;\n");
275 js.push_str("let isRecording = false;\n");
276 js.push_str("let audioContext = null;\n");
277 js.push_str("let mediaStream = null;\n\n");
278
279 js.push_str("const status = document.getElementById('status');\n");
281 js.push_str("const recordBtn = document.getElementById('record');\n");
282 js.push_str("const clearBtn = document.getElementById('clear');\n\n");
283
284 js.push_str("async function initApp() {\n");
286 js.push_str(" try {\n");
287 js.push_str(" status.textContent = 'Loading WASM...';\n");
288 js.push_str(" status.className = 'status-brick status-loading';\n");
289 js.push_str(" await init();\n\n");
290
291 if let Some(ref model_path) = config.model_path {
293 js.push_str(" status.textContent = 'Spawning worker...';\n");
294 js.push_str(" manager = new WorkerManager();\n");
295 js.push_str(&format!(" await manager.spawn('{model_path}');\n\n"));
296
297 js.push_str(" window.addEventListener('whisper-worker-ready', () => {\n");
298 js.push_str(" status.textContent = 'Loading model...';\n");
299 js.push_str(" manager.send_init();\n");
300 js.push_str(" }, { once: true });\n\n");
301
302 js.push_str(" window.addEventListener('whisper-model-loaded', (e) => {\n");
303 js.push_str(" const { sizeMb, loadTimeMs } = e.detail;\n");
304 js.push_str(" status.textContent = `Ready (${sizeMb.toFixed(1)}MB in ${(loadTimeMs/1000).toFixed(1)}s)`;\n");
305 js.push_str(" status.className = 'status-brick status-ready';\n");
306 js.push_str(" recordBtn.disabled = false;\n");
307 js.push_str(" });\n\n");
308
309 js.push_str(" window.addEventListener('whisper-transcription', (e) => {\n");
310 js.push_str(" const { text, isFinal } = e.detail;\n");
311 js.push_str(" if (isFinal) {\n");
312 js.push_str(
313 " document.getElementById('transcript').textContent += text + ' ';\n",
314 );
315 js.push_str(" document.getElementById('partial').textContent = '';\n");
316 js.push_str(" } else {\n");
317 js.push_str(" document.getElementById('partial').textContent = text;\n");
318 js.push_str(" }\n");
319 js.push_str(" });\n");
320 } else {
321 js.push_str(" status.textContent = 'Ready';\n");
322 js.push_str(" status.className = 'status-brick status-ready';\n");
323 js.push_str(" recordBtn.disabled = false;\n");
324 }
325
326 js.push_str(" } catch (err) {\n");
327 js.push_str(" status.textContent = 'Error: ' + err.message;\n");
328 js.push_str(" status.className = 'status-brick status-error';\n");
329 js.push_str(" console.error('[App] Init failed:', err);\n");
330 js.push_str(" }\n");
331 js.push_str("}\n\n");
332
333 if let Some(event_brick) = events {
335 js.push_str(&event_brick.to_event_js());
336 js.push('\n');
337 } else {
338 js.push_str("// Default event handlers\n");
340 js.push_str("recordBtn.addEventListener('click', async () => {\n");
341 js.push_str(" if (isRecording) {\n");
342 js.push_str(" if (manager) manager.stopRecording();\n");
343 js.push_str(" if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());\n");
344 js.push_str(" if (audioContext) audioContext.close();\n");
345 js.push_str(" isRecording = false;\n");
346 js.push_str(" recordBtn.textContent = 'Record';\n");
347 js.push_str(" recordBtn.classList.remove('recording');\n");
348 js.push_str(" status.textContent = 'Ready';\n");
349 js.push_str(" status.className = 'status-brick status-ready';\n");
350 js.push_str(" } else {\n");
351 js.push_str(" try {\n");
352 js.push_str(" mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n");
353 js.push_str(" audioContext = new AudioContext();\n");
354 js.push_str(" if (manager) manager.startRecording(audioContext.sampleRate);\n");
355 js.push_str(" isRecording = true;\n");
356 js.push_str(" recordBtn.textContent = 'Stop';\n");
357 js.push_str(" recordBtn.classList.add('recording');\n");
358 js.push_str(" status.textContent = 'Recording...';\n");
359 js.push_str(" status.className = 'status-brick status-recording';\n");
360 js.push_str(" } catch (err) {\n");
361 js.push_str(" status.textContent = 'Mic access denied';\n");
362 js.push_str(" status.className = 'status-brick status-error';\n");
363 js.push_str(" }\n");
364 js.push_str(" }\n");
365 js.push_str("});\n\n");
366
367 js.push_str("clearBtn.addEventListener('click', () => {\n");
368 js.push_str(" document.getElementById('transcript').textContent = '';\n");
369 js.push_str(" document.getElementById('partial').textContent = '';\n");
370 js.push_str("});\n\n");
371 }
372
373 js.push_str("initApp();\n");
375
376 js
377}
378
379#[must_use]
381pub fn create_whisper_worker_brick() -> WorkerBrick {
382 WorkerBrick::new("whisper-transcription")
383 .message(
384 BrickWorkerMessage::new("bootstrap", BrickWorkerMessageDirection::ToWorker)
385 .field("wasmUrl", FieldType::String)
386 .field("modelUrl", FieldType::String),
387 )
388 .message(BrickWorkerMessage::new(
389 "ready",
390 BrickWorkerMessageDirection::FromWorker,
391 ))
392 .message(
393 BrickWorkerMessage::new("init", BrickWorkerMessageDirection::ToWorker)
394 .field("ringBuffer", FieldType::SharedArrayBuffer),
395 )
396 .message(
397 BrickWorkerMessage::new("model_loaded", BrickWorkerMessageDirection::FromWorker)
398 .field("sizeMb", FieldType::Number)
399 .field("loadTimeMs", FieldType::Number),
400 )
401 .message(
402 BrickWorkerMessage::new("start", BrickWorkerMessageDirection::ToWorker)
403 .field("sampleRate", FieldType::Number),
404 )
405 .message(BrickWorkerMessage::new(
406 "stop",
407 BrickWorkerMessageDirection::ToWorker,
408 ))
409 .message(
410 BrickWorkerMessage::new("transcription", BrickWorkerMessageDirection::FromWorker)
411 .field("text", FieldType::String)
412 .field("isFinal", FieldType::Boolean),
413 )
414 .state("uninitialized")
415 .state("bootstrapped")
416 .state("loading")
417 .state("ready")
418 .state("recording")
419 .initial("uninitialized")
420 .transition("uninitialized", "bootstrap", "bootstrapped")
421 .transition("bootstrapped", "ready", "loading")
422 .transition("loading", "init", "loading")
423 .transition("loading", "model_loaded", "ready")
424 .transition("ready", "start", "recording")
425 .transition("recording", "stop", "ready")
426 .transition("recording", "transcription", "recording")
427}
428
429#[must_use]
431pub fn create_whisper_event_brick() -> EventBrick {
432 EventBrick::new()
433 .on(
434 "#record",
435 EventType::Click,
436 EventHandler::dispatch_state("toggle_recording"),
437 )
438 .on(
439 "#clear",
440 EventType::Click,
441 EventHandler::call_wasm("clear_transcript"),
442 )
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 use tempfile::TempDir;
450
451 #[test]
452 fn test_generate_html() {
453 let config = GenerateConfig {
454 title: "Test App".into(),
455 ..Default::default()
456 };
457
458 let html = generate_html(&config);
459
460 assert!(html.contains("<!DOCTYPE html>"));
461 assert!(html.contains("<title>Test App</title>"));
462 assert!(html.contains("id=\"status\""));
463 assert!(html.contains("id=\"record\""));
464 assert!(html.contains("aria-label"));
465 }
466
467 #[test]
468 fn test_generate_css() {
469 let css = generate_css();
470
471 assert!(css.contains("Generated by probar"));
472 assert!(css.contains(".status-brick"));
473 assert!(css.contains(".transcription-final"));
474 assert!(css.contains("@keyframes pulse"));
475 }
476
477 #[test]
478 fn test_generate_main_js() {
479 let config = GenerateConfig {
480 wasm_module: "./pkg/app.js".into(),
481 model_path: Some("/models/test.bin".into()),
482 ..Default::default()
483 };
484
485 let js = generate_main_js(None, &config);
486
487 assert!(js.contains("Generated by probar"));
488 assert!(js.contains("import init"));
489 assert!(js.contains("initApp"));
490 assert!(js.contains("whisper-worker-ready"));
491 }
492
493 #[test]
494 fn test_generate_from_bricks() {
495 let temp_dir = TempDir::new().unwrap();
496 let config = GenerateConfig {
497 app_name: "test".into(),
498 title: "Test".into(),
499 output_dir: temp_dir.path().to_path_buf(),
500 ..Default::default()
501 };
502
503 let worker = create_whisper_worker_brick();
504 let events = create_whisper_event_brick();
505
506 let result = generate_from_bricks(Some(&worker), Some(&events), None, &config);
507
508 assert!(result.is_ok());
509 let result = result.unwrap();
510
511 assert!(result.files_written.len() >= 4);
512 assert!(result.worker_js.is_some());
513 assert!(temp_dir.path().join("index.html").exists());
514 assert!(temp_dir.path().join("style.css").exists());
515 assert!(temp_dir.path().join("main.js").exists());
516 assert!(temp_dir.path().join("worker.js").exists());
517 }
518
519 #[test]
520 fn test_create_whisper_worker_brick() {
521 let worker = create_whisper_worker_brick();
522
523 let js = worker.to_worker_js();
524 assert!(
526 js.contains("WhisperTranscription"),
527 "Missing WhisperTranscription"
528 );
529 assert!(js.contains("bootstrap"), "Missing bootstrap (ToWorker)");
531 assert!(js.contains("init"), "Missing init (ToWorker)");
532 assert!(js.contains("start"), "Missing start (ToWorker)");
533 assert!(js.contains("stop"), "Missing stop (ToWorker)");
534
535 let rust = worker.to_rust_bindings();
536 assert!(rust.contains("pub enum ToWorker"));
537 assert!(rust.contains("pub enum FromWorker"));
538 assert!(
540 rust.contains("ModelLoaded"),
541 "Missing ModelLoaded (FromWorker)"
542 );
543 assert!(
544 rust.contains("Transcription"),
545 "Missing Transcription (FromWorker)"
546 );
547 }
548
549 #[test]
552 fn test_generate_config_default() {
553 let config = GenerateConfig::default();
554 assert_eq!(config.app_name, "app");
555 assert_eq!(config.wasm_module, "./pkg/app.js");
556 assert!(config.model_path.is_none());
557 assert_eq!(config.title, "Probar Application");
558 assert_eq!(config.output_dir, std::path::PathBuf::from("."));
559 }
560
561 #[test]
562 fn test_generate_main_js_no_model_path() {
563 let config = GenerateConfig {
564 model_path: None,
565 ..Default::default()
566 };
567
568 let js = generate_main_js(None, &config);
569
570 assert!(js.contains("status.textContent = 'Ready'"));
572 assert!(js.contains("status.className = 'status-brick status-ready'"));
573 assert!(js.contains("recordBtn.disabled = false"));
574 assert!(!js.contains("whisper-worker-ready"));
576 }
577
578 #[test]
579 fn test_generate_main_js_with_event_brick() {
580 let config = GenerateConfig::default();
581 let events = create_whisper_event_brick();
582
583 let js = generate_main_js(Some(&events), &config);
584
585 assert!(js.contains("#record"));
587 assert!(js.contains("#clear"));
588 assert!(!js.contains("// Default event handlers"));
590 }
591
592 #[test]
593 fn test_create_whisper_event_brick() {
594 let events = create_whisper_event_brick();
595
596 let js = events.to_event_js();
597
598 assert!(js.contains("#record"));
599 assert!(js.contains("#clear"));
600 assert!(js.contains("click"));
601 }
602
603 #[test]
604 fn test_generate_from_bricks_no_worker_no_audio() {
605 let temp_dir = TempDir::new().unwrap();
606 let config = GenerateConfig {
607 output_dir: temp_dir.path().to_path_buf(),
608 ..Default::default()
609 };
610
611 let result = generate_from_bricks(None, None, None, &config);
612
613 assert!(result.is_ok());
614 let result = result.unwrap();
615
616 assert!(result.worker_js.is_none());
617 assert!(result.worklet_js.is_none());
618 assert_eq!(result.files_written.len(), 3); }
620
621 #[test]
622 fn test_generate_from_bricks_with_audio() {
623 use jugar_probar::brick::AudioBrick;
624
625 let temp_dir = TempDir::new().unwrap();
626 let config = GenerateConfig {
627 output_dir: temp_dir.path().to_path_buf(),
628 ..Default::default()
629 };
630
631 let audio = AudioBrick::new("test-audio")
632 .inputs(1)
633 .outputs(1)
634 .sample_rate(16000);
635
636 let result = generate_from_bricks(None, None, Some(&audio), &config);
637
638 assert!(result.is_ok());
639 let result = result.unwrap();
640
641 assert!(result.worklet_js.is_some());
642 assert!(temp_dir.path().join("audio-worklet.js").exists());
643 }
644
645 #[test]
646 fn test_generate_from_bricks_invalid_output_dir() {
647 let config = GenerateConfig {
648 output_dir: std::path::PathBuf::from("/nonexistent/readonly/path"),
649 ..Default::default()
650 };
651
652 let result = generate_from_bricks(None, None, None, &config);
653
654 assert!(result.is_err());
655 assert!(result.unwrap_err().contains("Failed to create output dir"));
656 }
657
658 #[test]
659 fn test_generate_result_fields() {
660 let temp_dir = TempDir::new().unwrap();
661 let config = GenerateConfig {
662 title: "Test Result Fields".into(),
663 output_dir: temp_dir.path().to_path_buf(),
664 ..Default::default()
665 };
666
667 let result = generate_from_bricks(None, None, None, &config).unwrap();
668
669 assert!(result.html.contains("Test Result Fields"));
670 assert!(result.css.contains("Generated by probar"));
671 assert!(result.main_js.contains("initApp"));
672 }
673
674 #[test]
675 fn test_generate_html_escaping() {
676 let config = GenerateConfig {
677 title: "Test & Special <chars>".into(),
678 ..Default::default()
679 };
680
681 let html = generate_html(&config);
682
683 assert!(html.contains("Test & Special <chars>"));
685 }
686
687 #[test]
688 fn test_generate_main_js_imports() {
689 let config = GenerateConfig {
690 wasm_module: "./custom/path.js".into(),
691 ..Default::default()
692 };
693
694 let js = generate_main_js(None, &config);
695
696 assert!(js.contains("import init, { WorkerManager } from './custom/path.js'"));
697 }
698}