Connect Dream QA voice input to Modal ASR

#24
README.md CHANGED
@@ -103,11 +103,12 @@ The public Space stays lightweight and can call private Modal endpoints through
103
 
104
  - `DREAM_CUSTOMS_TEXT_ENDPOINT`: Modal text route for `openbmb/MiniCPM5-1B`.
105
  - `DREAM_CUSTOMS_VISION_ENDPOINT`: Modal vision route for `openbmb/MiniCPM-V-4.6`.
 
106
  - `DREAM_CUSTOMS_HOSTED_TOKEN`: shared bearer token checked by Modal and sent by the Space.
107
 
108
  Set these only as Hugging Face Space repository secrets or local shell variables. Do not store values in `.env`, docs, logs, screenshots, or git. Missing endpoints or route failures fall back to deterministic demo behavior.
109
 
110
- The Gradio UI defaults to `modal` for both text and vision backends, so a configured Space enters the private Modal MiniCPM endpoints first. The `demo` backend remains available in developer settings and as the deterministic fallback path when hosted routes are missing or fail.
111
 
112
  The Hugging Face Space may run on ZeroGPU for hackathon hardware eligibility. `dream_customs.zerogpu` registers a lightweight `@spaces.GPU` startup probe so ZeroGPU accepts the app, but real MiniCPM inference still happens on the private Modal backend.
113
 
 
103
 
104
  - `DREAM_CUSTOMS_TEXT_ENDPOINT`: Modal text route for `openbmb/MiniCPM5-1B`.
105
  - `DREAM_CUSTOMS_VISION_ENDPOINT`: Modal vision route for `openbmb/MiniCPM-V-4.6`.
106
+ - `DREAM_CUSTOMS_ASR_ENDPOINT`: Modal ASR adapter route for voice-note transcription.
107
  - `DREAM_CUSTOMS_HOSTED_TOKEN`: shared bearer token checked by Modal and sent by the Space.
108
 
109
  Set these only as Hugging Face Space repository secrets or local shell variables. Do not store values in `.env`, docs, logs, screenshots, or git. Missing endpoints or route failures fall back to deterministic demo behavior.
110
 
111
+ The Gradio UI defaults to `modal` for text, vision, and voice transcription backends, so a configured Space enters the private Modal routes first. The `demo` backend remains available in developer settings and as the deterministic fallback path when hosted routes are missing or fail.
112
 
113
  The Hugging Face Space may run on ZeroGPU for hackathon hardware eligibility. `dream_customs.zerogpu` registers a lightweight `@spaces.GPU` startup probe so ZeroGPU accepts the app, but real MiniCPM inference still happens on the private Modal backend.
114
 
docs/handoff.md CHANGED
@@ -181,7 +181,7 @@ Hugging Face Space Gradio UI
181
  -> MiniCPM5-1B text generation and MiniCPM-V-4.6 visual clue extraction
182
  ```
183
 
184
- The Space defaults to `modal` for both text and vision backends, so a configured Space enters the private Modal MiniCPM endpoints first. The `model` auto route remains available for compatibility, and the `demo` backend remains available in developer settings as the deterministic fallback path.
185
 
186
  The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
187
 
 
181
  -> MiniCPM5-1B text generation and MiniCPM-V-4.6 visual clue extraction
182
  ```
183
 
184
+ The Space defaults to `modal` for text, vision, and voice transcription backends, so a configured Space enters the private Modal routes first. The `model` auto route remains available for compatibility, and the `demo` backend remains available in developer settings as the deterministic fallback path.
185
 
186
  The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
187
 
docs/smoke/2026-06-10-modal-asr-voice-input-smoke.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal ASR Voice Input Smoke - 2026-06-10
2
+
3
+ ## Scope
4
+
5
+ Verify that Dream QA voice input uses a real Gradio audio component and that the backend can route recorded/uploaded audio to Modal ASR.
6
+
7
+ No endpoint URLs, bearer tokens, Hugging Face tokens, Modal tokens, or secret values should be printed or recorded.
8
+
9
+ ## Evidence
10
+
11
+ - Focused regression: `.venv/bin/python -m pytest tests/test_app_logic.py::test_defaults_use_modal_model_entrypoint tests/test_ui_actions.py::test_voice_input_uses_gradio_audio_file_for_modal_asr tests/test_modal_contract.py::test_decode_audio_payload_accepts_audio_key -q` -> 3 passed.
12
+ - Full pytest: `.venv/bin/python -m pytest -q` -> 101 passed, 2 warnings.
13
+ - Today Tip eval: `.venv/bin/python scripts/evaluate_today_tip_quality.py` -> 11 cases passed.
14
+ - Whitespace check: `git diff --check` -> passed.
15
+ - Local Gradio config: `Voice note` is a real `audio` component with `sources=["microphone", "upload"]`, `format="wav"`; `Voice input` default value is `modal`.
16
+ - Modal deploy: `dream-customs-minicpm-backend` redeployed with a new `asr` web function. CLI output URLs were redacted.
17
+ - Modal ASR auth smoke: route reachable without printing URL; unauthenticated request returned `status="unauthorized"`, confirming the route exists and requires the hosted token.
18
+ - HF Space secret update attempt: API returned 403 for writing `DREAM_CUSTOMS_ASR_ENDPOINT`. The app now derives the ASR endpoint from the existing Modal text endpoint when the explicit ASR endpoint secret is missing, so no endpoint value was printed or stored.
19
+ - Public Space check: pending.
docs/superpowers/plans/2026-06-10-modal-asr-voice-input.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal ASR Voice Input Plan - 2026-06-10
2
+
3
+ ## Goal
4
+
5
+ Replace the browser-only voice placeholder with a real Gradio audio recording/upload input that sends a filepath into the existing Dream QA intake, then connect voice transcription to a Modal-hosted ASR adapter.
6
+
7
+ ## Root Cause
8
+
9
+ - The visible round microphone was custom JavaScript using browser speech recognition.
10
+ - The backend `audio_input` was only `gr.State(None)`, so no recorded file reached `HostedASRClient`.
11
+ - The Modal backend had text and vision routes but no `/asr` route.
12
+
13
+ ## Guardrails
14
+
15
+ - ASR only transcribes audio; dream understanding remains MiniCPM-driven.
16
+ - Do not store or print endpoint URLs, hosted tokens, HF tokens, Modal tokens, or secret values.
17
+ - Keep text-only fallback working when audio permission, recording, upload, or hosted ASR fails.
18
+
19
+ ## Tasks
20
+
21
+ - [x] Add failing tests for default Modal ASR, real Gradio audio input, and audio payload decoding.
22
+ - [x] Change default ASR backend to `modal`.
23
+ - [x] Replace the fake browser dictation input with `gr.Audio(sources=["microphone", "upload"], type="filepath")`.
24
+ - [x] Add Modal `/asr` route with a small ASR adapter.
25
+ - [x] Run full tests, eval, local UI smoke, and Modal deploy/smoke.
26
+ - [ ] Run public Space verification after HF merge.
27
+ - [ ] Commit, push, sync to Hugging Face Space, merge, and re-check Chrome.
28
+
29
+ ## Progress
30
+
31
+ - Full pytest passes with 101 tests.
32
+ - Today Tip eval passes 11 cases.
33
+ - Local Gradio config confirms real audio input and Modal ASR default.
34
+ - Modal deploy created the ASR route; unauthenticated route smoke confirmed the route is reachable and protected.
35
+ - HF API cannot write Space secrets with the current token, so code derives the ASR endpoint from the existing Modal text endpoint when `DREAM_CUSTOMS_ASR_ENDPOINT` is absent.
dream_customs/app_logic.py CHANGED
@@ -1,6 +1,7 @@
1
  import json
2
  import os
3
  from typing import Any, Tuple
 
4
 
5
  from dream_customs.models import (
6
  FakeASRClient,
@@ -12,7 +13,7 @@ from dream_customs.models import (
12
  OllamaTextClient,
13
  OllamaVisionClient,
14
  )
15
- from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
16
  from dream_customs.pipeline import (
17
  add_evidence,
18
  answer_question,
@@ -75,6 +76,19 @@ def _as_int(value: Any, default: int) -> int:
75
  return default
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def _client_settings(
79
  text_endpoint: str = "",
80
  vision_endpoint: str = "",
@@ -88,15 +102,19 @@ def _client_settings(
88
  vision_temperature: Any = DEFAULT_VISION_TEMPERATURE,
89
  text_max_tokens: Any = DEFAULT_TEXT_MAX_TOKENS,
90
  vision_max_tokens: Any = DEFAULT_VISION_MAX_TOKENS,
91
- asr_backend: str = "demo",
92
  asr_endpoint: str = "",
93
  asr_timeout_seconds: Any = DEFAULT_ASR_TIMEOUT_SECONDS,
94
  text_latency_budget_ms: Any = DEFAULT_TEXT_LATENCY_BUDGET_MS,
95
  vision_latency_budget_ms: Any = DEFAULT_VISION_LATENCY_BUDGET_MS,
96
  asr_latency_budget_ms: Any = DEFAULT_ASR_LATENCY_BUDGET_MS,
97
  ) -> dict:
 
 
 
 
98
  return {
99
- "text_endpoint": (text_endpoint or os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", "")).strip(),
100
  "vision_endpoint": (vision_endpoint or os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "")).strip(),
101
  "hosted_token": (hosted_token or os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "")).strip(),
102
  "ollama_url": (ollama_url or os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434")).strip(),
@@ -108,8 +126,8 @@ def _client_settings(
108
  "vision_temperature": max(0.0, min(_as_float(vision_temperature, DEFAULT_VISION_TEMPERATURE), 0.7)),
109
  "text_max_tokens": max(64, min(_as_int(text_max_tokens, DEFAULT_TEXT_MAX_TOKENS), 1200)),
110
  "vision_max_tokens": max(64, min(_as_int(vision_max_tokens, DEFAULT_VISION_MAX_TOKENS), 800)),
111
- "asr_backend": (asr_backend or "demo").lower(),
112
- "asr_endpoint": (asr_endpoint or os.getenv("DREAM_CUSTOMS_ASR_ENDPOINT", "")).strip(),
113
  "asr_timeout_seconds": max(1.0, _as_float(asr_timeout_seconds, DEFAULT_ASR_TIMEOUT_SECONDS)),
114
  "text_latency_budget_ms": max(0, _as_int(text_latency_budget_ms, DEFAULT_TEXT_LATENCY_BUDGET_MS)),
115
  "vision_latency_budget_ms": max(0, _as_int(vision_latency_budget_ms, DEFAULT_VISION_LATENCY_BUDGET_MS)),
 
1
  import json
2
  import os
3
  from typing import Any, Tuple
4
+ from urllib.parse import urlparse, urlunparse
5
 
6
  from dream_customs.models import (
7
  FakeASRClient,
 
13
  OllamaTextClient,
14
  OllamaVisionClient,
15
  )
16
+ from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
17
  from dream_customs.pipeline import (
18
  add_evidence,
19
  answer_question,
 
76
  return default
77
 
78
 
79
+ def _derive_modal_asr_endpoint(text_endpoint: str) -> str:
80
+ if not text_endpoint:
81
+ return ""
82
+ parsed = urlparse(text_endpoint.strip())
83
+ if parsed.netloc.endswith("-text.modal.run"):
84
+ netloc = f"{parsed.netloc.removesuffix('-text.modal.run')}-asr.modal.run"
85
+ return urlunparse(parsed._replace(netloc=netloc))
86
+ if parsed.path.rstrip("/").endswith("/text"):
87
+ path = f"{parsed.path.rstrip('/')[:-len('/text')]}/asr"
88
+ return urlunparse(parsed._replace(path=path))
89
+ return ""
90
+
91
+
92
  def _client_settings(
93
  text_endpoint: str = "",
94
  vision_endpoint: str = "",
 
102
  vision_temperature: Any = DEFAULT_VISION_TEMPERATURE,
103
  text_max_tokens: Any = DEFAULT_TEXT_MAX_TOKENS,
104
  vision_max_tokens: Any = DEFAULT_VISION_MAX_TOKENS,
105
+ asr_backend: str = DEFAULT_ASR_BACKEND,
106
  asr_endpoint: str = "",
107
  asr_timeout_seconds: Any = DEFAULT_ASR_TIMEOUT_SECONDS,
108
  text_latency_budget_ms: Any = DEFAULT_TEXT_LATENCY_BUDGET_MS,
109
  vision_latency_budget_ms: Any = DEFAULT_VISION_LATENCY_BUDGET_MS,
110
  asr_latency_budget_ms: Any = DEFAULT_ASR_LATENCY_BUDGET_MS,
111
  ) -> dict:
112
+ resolved_text_endpoint = (text_endpoint or os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", "")).strip()
113
+ resolved_asr_endpoint = (asr_endpoint or os.getenv("DREAM_CUSTOMS_ASR_ENDPOINT", "")).strip()
114
+ if not resolved_asr_endpoint:
115
+ resolved_asr_endpoint = _derive_modal_asr_endpoint(resolved_text_endpoint)
116
  return {
117
+ "text_endpoint": resolved_text_endpoint,
118
  "vision_endpoint": (vision_endpoint or os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "")).strip(),
119
  "hosted_token": (hosted_token or os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "")).strip(),
120
  "ollama_url": (ollama_url or os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434")).strip(),
 
126
  "vision_temperature": max(0.0, min(_as_float(vision_temperature, DEFAULT_VISION_TEMPERATURE), 0.7)),
127
  "text_max_tokens": max(64, min(_as_int(text_max_tokens, DEFAULT_TEXT_MAX_TOKENS), 1200)),
128
  "vision_max_tokens": max(64, min(_as_int(vision_max_tokens, DEFAULT_VISION_MAX_TOKENS), 800)),
129
+ "asr_backend": (asr_backend or DEFAULT_ASR_BACKEND).lower(),
130
+ "asr_endpoint": resolved_asr_endpoint,
131
  "asr_timeout_seconds": max(1.0, _as_float(asr_timeout_seconds, DEFAULT_ASR_TIMEOUT_SECONDS)),
132
  "text_latency_budget_ms": max(0, _as_int(text_latency_budget_ms, DEFAULT_TEXT_LATENCY_BUDGET_MS)),
133
  "vision_latency_budget_ms": max(0, _as_int(vision_latency_budget_ms, DEFAULT_VISION_LATENCY_BUDGET_MS)),
dream_customs/defaults.py CHANGED
@@ -1,2 +1,3 @@
1
  DEFAULT_TEXT_BACKEND = "modal"
2
  DEFAULT_VISION_BACKEND = "modal"
 
 
1
  DEFAULT_TEXT_BACKEND = "modal"
2
  DEFAULT_VISION_BACKEND = "modal"
3
+ DEFAULT_ASR_BACKEND = "modal"
dream_customs/ui/app.py CHANGED
@@ -17,7 +17,7 @@ from dream_customs.app_logic import (
17
  DEFAULT_VISION_MODEL,
18
  DEFAULT_VISION_TEMPERATURE,
19
  )
20
- from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
21
  from dream_customs.ui.actions import (
22
  answer_to_card_action,
23
  initial_mobile_state,
@@ -370,6 +370,10 @@ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
370
  """.strip()
371
 
372
 
 
 
 
 
373
  def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
374
  return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
375
 
@@ -403,7 +407,7 @@ def build_demo() -> gr.Blocks:
403
  initial = _load_view(initial_view)
404
  initial_copy = copy_for(DEFAULT_LANGUAGE)
405
 
406
- with gr.Blocks(css=CSS, js=VOICE_JS, title=APP_TITLE) as demo:
407
  session_state = gr.State(initial_state)
408
  view_state = gr.State(initial_view)
409
 
@@ -423,8 +427,14 @@ def build_demo() -> gr.Blocks:
423
  value="",
424
  elem_classes=["dc-dream-text"],
425
  )
426
- mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))
427
- audio_input = gr.State(None)
 
 
 
 
 
 
428
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
429
  with gr.Row(elem_classes=["dc-submit-row"]):
430
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
@@ -494,11 +504,11 @@ def build_demo() -> gr.Blocks:
494
  asr_backend = gr.Dropdown(
495
  label="Voice input",
496
  choices=[
497
- ("Browser dictation now", "demo"),
498
- ("Modal ASR endpoint, planned", "modal"),
499
  ("Hugging Face ASR endpoint, planned", "huggingface"),
500
  ],
501
- value="demo",
502
  )
503
  with gr.Accordion("Advanced endpoints", open=False, elem_classes=["dc-dev-advanced"]):
504
  text_endpoint = gr.Textbox(label="Text endpoint", value="")
@@ -653,7 +663,7 @@ def build_demo() -> gr.Blocks:
653
  _notice_html({"notice": copy["notice_record"], "status": "record"}),
654
  _section_title_html(1, copy["dream_label"]),
655
  gr.update(label=copy["dream_label"], placeholder=copy["dream_placeholder"]),
656
- _mic_html(selected_language),
657
  _field_tip_html(selected_language),
658
  gr.update(value=copy["example_button"]),
659
  gr.update(value=copy["submit_button"]),
@@ -682,7 +692,7 @@ def build_demo() -> gr.Blocks:
682
  notice,
683
  dream_section_html,
684
  dream_text,
685
- mic_html,
686
  field_tip_html,
687
  example_button,
688
  submit_button,
 
17
  DEFAULT_VISION_MODEL,
18
  DEFAULT_VISION_TEMPERATURE,
19
  )
20
+ from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
21
  from dream_customs.ui.actions import (
22
  answer_to_card_action,
23
  initial_mobile_state,
 
370
  """.strip()
371
 
372
 
373
+ def _voice_help_html(language: str = DEFAULT_LANGUAGE) -> str:
374
+ return f"<p class=\"dc-voice-help\">{escape(copy_for(language)['voice_help'])}</p>"
375
+
376
+
377
  def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
378
  return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
379
 
 
407
  initial = _load_view(initial_view)
408
  initial_copy = copy_for(DEFAULT_LANGUAGE)
409
 
410
+ with gr.Blocks(css=CSS, title=APP_TITLE) as demo:
411
  session_state = gr.State(initial_state)
412
  view_state = gr.State(initial_view)
413
 
 
427
  value="",
428
  elem_classes=["dc-dream-text"],
429
  )
430
+ voice_help_html = gr.HTML(_voice_help_html(DEFAULT_LANGUAGE))
431
+ audio_input = gr.Audio(
432
+ label=initial_copy["voice_label"],
433
+ sources=["microphone", "upload"],
434
+ type="filepath",
435
+ format="wav",
436
+ elem_classes=["dc-voice-input"],
437
+ )
438
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
439
  with gr.Row(elem_classes=["dc-submit-row"]):
440
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
 
504
  asr_backend = gr.Dropdown(
505
  label="Voice input",
506
  choices=[
507
+ ("Modal ASR endpoint", "modal"),
508
+ ("Demo: mark voice received", "demo"),
509
  ("Hugging Face ASR endpoint, planned", "huggingface"),
510
  ],
511
+ value=DEFAULT_ASR_BACKEND,
512
  )
513
  with gr.Accordion("Advanced endpoints", open=False, elem_classes=["dc-dev-advanced"]):
514
  text_endpoint = gr.Textbox(label="Text endpoint", value="")
 
663
  _notice_html({"notice": copy["notice_record"], "status": "record"}),
664
  _section_title_html(1, copy["dream_label"]),
665
  gr.update(label=copy["dream_label"], placeholder=copy["dream_placeholder"]),
666
+ _voice_help_html(selected_language),
667
  _field_tip_html(selected_language),
668
  gr.update(value=copy["example_button"]),
669
  gr.update(value=copy["submit_button"]),
 
692
  notice,
693
  dream_section_html,
694
  dream_text,
695
+ voice_help_html,
696
  field_tip_html,
697
  example_button,
698
  submit_button,
dream_customs/ui/copy.py CHANGED
@@ -23,6 +23,8 @@ APP_COPY = {
23
  "mic_listening": "Listening. Say the dream fragment when you are ready.",
24
  "mic_done": "Added to the dream note.",
25
  "mic_empty": "No speech detected. Tap again if you want to retry.",
 
 
26
  "field_tip": "People, places, feelings, colors, or the question you woke up with are all useful.",
27
  "example_button": "Try example",
28
  "submit_button": "Continue",
@@ -72,6 +74,8 @@ APP_COPY = {
72
  "mic_listening": "正在听。准备好后说出梦境片段。",
73
  "mic_done": "已加入梦境记录。",
74
  "mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
 
 
75
  "field_tip": "可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。",
76
  "example_button": "试试示例",
77
  "submit_button": "继续解梦 →",
 
23
  "mic_listening": "Listening. Say the dream fragment when you are ready.",
24
  "mic_done": "Added to the dream note.",
25
  "mic_empty": "No speech detected. Tap again if you want to retry.",
26
+ "voice_label": "Voice note",
27
+ "voice_help": "Record or upload a short voice note. It is transcribed by the ASR adapter when you continue.",
28
  "field_tip": "People, places, feelings, colors, or the question you woke up with are all useful.",
29
  "example_button": "Try example",
30
  "submit_button": "Continue",
 
74
  "mic_listening": "正在听。准备好后说出梦境片段。",
75
  "mic_done": "已加入梦境记录。",
76
  "mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
77
+ "voice_label": "语音片段",
78
+ "voice_help": "可以录音或上传一小段语音。点击继续后,ASR 适配器会先转写它。",
79
  "field_tip": "可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。",
80
  "example_button": "试试示例",
81
  "submit_button": "继续解梦 →",
dream_customs/ui/styles.py CHANGED
@@ -394,6 +394,26 @@ body,
394
  display: none !important;
395
  }
396
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  .dc-mic-control {
398
  align-items: end;
399
  bottom: 68px;
 
394
  display: none !important;
395
  }
396
 
397
+ .dc-voice-help {
398
+ color: var(--dc-muted);
399
+ font-size: 0.86rem;
400
+ line-height: 1.4;
401
+ margin: 12px 0 6px;
402
+ }
403
+
404
+ .dc-voice-input {
405
+ margin-top: 0 !important;
406
+ }
407
+
408
+ .dc-voice-input .wrap,
409
+ .dc-voice-input .container,
410
+ .dc-voice-input .input-container {
411
+ background: rgba(255, 253, 248, 0.9) !important;
412
+ border-color: var(--dc-line) !important;
413
+ border-radius: var(--dc-radius-sm) !important;
414
+ color: var(--dc-ink) !important;
415
+ }
416
+
417
  .dc-mic-control {
418
  align-items: end;
419
  bottom: 68px;
modal_backend/contracts.py CHANGED
@@ -54,6 +54,18 @@ def decode_image_payload(payload: Dict[str, Any]) -> bytes:
54
  return base64.b64decode(str(encoded))
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def ensure_authorized(authorization_header: str, expected_token: str) -> None:
58
  expected_token = expected_token.strip()
59
  if not expected_token:
 
54
  return base64.b64decode(str(encoded))
55
 
56
 
57
+ def decode_audio_payload(payload: Dict[str, Any]) -> tuple[bytes, str]:
58
+ encoded = payload.get("audio")
59
+ if not encoded and isinstance(payload.get("audios"), list) and payload["audios"]:
60
+ encoded = payload["audios"][0]
61
+ if not encoded:
62
+ raise ValueError("Missing audio payload.")
63
+ if isinstance(encoded, bytes):
64
+ encoded = encoded.decode("ascii")
65
+ filename = _clean_text(payload.get("filename")) or "dream-voice.wav"
66
+ return base64.b64decode(str(encoded)), filename
67
+
68
+
69
  def ensure_authorized(authorization_header: str, expected_token: str) -> None:
70
  expected_token = expected_token.strip()
71
  if not expected_token:
modal_backend/dream_customs_modal.py CHANGED
@@ -9,6 +9,7 @@ from fastapi import Body, Header
9
 
10
  from modal_backend.contracts import (
11
  AuthError,
 
12
  decode_image_payload,
13
  ensure_authorized,
14
  normalize_text_payload,
@@ -19,6 +20,7 @@ from modal_backend.contracts import (
19
  APP_NAME = "dream-customs-minicpm-backend"
20
  TEXT_MODEL = "openbmb/MiniCPM5-1B"
21
  VISION_MODEL = "openbmb/MiniCPM-V-4.6"
 
22
  MINUTES = 60
23
 
24
  app = modal.App(APP_NAME)
@@ -44,6 +46,8 @@ image = (
44
  "torch",
45
  "torchvision",
46
  "transformers>=4.56",
 
 
47
  )
48
  .add_local_dir("modal_backend", remote_path="/root/modal_backend")
49
  )
@@ -57,6 +61,7 @@ secrets = [
57
 
58
  _TEXT_PIPE = None
59
  _VISION_PIPE = None
 
60
 
61
 
62
  def _expected_token() -> str:
@@ -113,6 +118,20 @@ def _load_vision_pipe():
113
  return _VISION_PIPE
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def _messages_from_payload(payload: Dict[str, Any], prompt: str) -> list[Dict[str, str]]:
117
  messages = payload.get("messages")
118
  if isinstance(messages, list) and messages:
@@ -299,6 +318,7 @@ def health() -> Dict[str, str]:
299
  "app": APP_NAME,
300
  "text_model": TEXT_MODEL,
301
  "vision_model": VISION_MODEL,
 
302
  }
303
 
304
 
@@ -390,3 +410,35 @@ async def vision(
390
  ]
391
  result = pipe(text=messages, max_new_tokens=int(payload.get("max_tokens", 320)))
392
  return response_payload(_stringify_pipeline_result(result))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  from modal_backend.contracts import (
11
  AuthError,
12
+ decode_audio_payload,
13
  decode_image_payload,
14
  ensure_authorized,
15
  normalize_text_payload,
 
20
  APP_NAME = "dream-customs-minicpm-backend"
21
  TEXT_MODEL = "openbmb/MiniCPM5-1B"
22
  VISION_MODEL = "openbmb/MiniCPM-V-4.6"
23
+ ASR_MODEL = "openai/whisper-tiny"
24
  MINUTES = 60
25
 
26
  app = modal.App(APP_NAME)
 
46
  "torch",
47
  "torchvision",
48
  "transformers>=4.56",
49
+ "librosa",
50
+ "soundfile",
51
  )
52
  .add_local_dir("modal_backend", remote_path="/root/modal_backend")
53
  )
 
61
 
62
  _TEXT_PIPE = None
63
  _VISION_PIPE = None
64
+ _ASR_PIPE = None
65
 
66
 
67
  def _expected_token() -> str:
 
118
  return _VISION_PIPE
119
 
120
 
121
+ def _load_asr_pipe():
122
+ global _ASR_PIPE
123
+ if _ASR_PIPE is None:
124
+ from transformers import pipeline
125
+
126
+ _ASR_PIPE = pipeline(
127
+ "automatic-speech-recognition",
128
+ model=os.getenv("DREAM_CUSTOMS_ASR_MODEL", ASR_MODEL),
129
+ device_map="auto",
130
+ torch_dtype="auto",
131
+ )
132
+ return _ASR_PIPE
133
+
134
+
135
  def _messages_from_payload(payload: Dict[str, Any], prompt: str) -> list[Dict[str, str]]:
136
  messages = payload.get("messages")
137
  if isinstance(messages, list) and messages:
 
318
  "app": APP_NAME,
319
  "text_model": TEXT_MODEL,
320
  "vision_model": VISION_MODEL,
321
+ "asr_model": ASR_MODEL,
322
  }
323
 
324
 
 
410
  ]
411
  result = pipe(text=messages, max_new_tokens=int(payload.get("max_tokens", 320)))
412
  return response_payload(_stringify_pipeline_result(result))
413
+
414
+
415
+ @app.function(
416
+ image=image,
417
+ gpu="L4",
418
+ timeout=10 * MINUTES,
419
+ scaledown_window=5 * MINUTES,
420
+ volumes={"/root/.cache/huggingface": hf_cache},
421
+ secrets=secrets,
422
+ )
423
+ @modal.fastapi_endpoint(method="POST", docs=True)
424
+ async def asr(
425
+ payload: Dict[str, Any] = Body(...),
426
+ authorization: str = Header(""),
427
+ ):
428
+ try:
429
+ ensure_authorized(authorization, _expected_token())
430
+ except AuthError as exc:
431
+ return _json_error(str(exc), status="unauthorized")
432
+ try:
433
+ audio_bytes, filename = decode_audio_payload(payload)
434
+ except ValueError as exc:
435
+ return _json_error(str(exc))
436
+
437
+ suffix = os.path.splitext(filename)[1] or ".wav"
438
+ pipe = _load_asr_pipe()
439
+ with tempfile.NamedTemporaryFile(suffix=suffix) as temp_file:
440
+ temp_file.write(audio_bytes)
441
+ temp_file.flush()
442
+ result = pipe(temp_file.name)
443
+ transcript = _stringify_pipeline_result(result)
444
+ return {"status": "ok", "transcript": transcript, "response": transcript}
tests/test_app_logic.py CHANGED
@@ -2,6 +2,7 @@ import json
2
 
3
  from dream_customs.app_logic import (
4
  _clients,
 
5
  _debug_json,
6
  add_material_action,
7
  ask_another_question_action,
@@ -12,13 +13,14 @@ from dream_customs.app_logic import (
12
  seal_pact_action,
13
  start_declaration_action,
14
  )
15
- from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
16
  from dream_customs.models import HostedASRClient, HostedMiniCPMTextClient, HostedMiniCPMVisionClient
17
 
18
 
19
  def test_defaults_use_modal_model_entrypoint():
20
  assert DEFAULT_TEXT_BACKEND == "modal"
21
  assert DEFAULT_VISION_BACKEND == "modal"
 
22
 
23
 
24
  def test_run_customs_once_generates_demo_outputs():
@@ -137,3 +139,12 @@ def test_developer_settings_configure_hosted_clients():
137
  assert vision_client.temperature == 0.22
138
  assert text_client.max_tokens == 256
139
  assert vision_client.max_tokens == 128
 
 
 
 
 
 
 
 
 
 
2
 
3
  from dream_customs.app_logic import (
4
  _clients,
5
+ _client_settings,
6
  _debug_json,
7
  add_material_action,
8
  ask_another_question_action,
 
13
  seal_pact_action,
14
  start_declaration_action,
15
  )
16
+ from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
17
  from dream_customs.models import HostedASRClient, HostedMiniCPMTextClient, HostedMiniCPMVisionClient
18
 
19
 
20
  def test_defaults_use_modal_model_entrypoint():
21
  assert DEFAULT_TEXT_BACKEND == "modal"
22
  assert DEFAULT_VISION_BACKEND == "modal"
23
+ assert DEFAULT_ASR_BACKEND == "modal"
24
 
25
 
26
  def test_run_customs_once_generates_demo_outputs():
 
139
  assert vision_client.temperature == 0.22
140
  assert text_client.max_tokens == 256
141
  assert vision_client.max_tokens == 128
142
+
143
+
144
+ def test_asr_endpoint_derives_from_modal_text_endpoint_when_secret_is_missing():
145
+ settings = _client_settings(
146
+ text_endpoint="https://workspace--dream-customs-minicpm-backend-text.modal.run",
147
+ asr_endpoint="",
148
+ )
149
+
150
+ assert settings["asr_endpoint"] == "https://workspace--dream-customs-minicpm-backend-asr.modal.run"
tests/test_modal_contract.py CHANGED
@@ -2,6 +2,7 @@ import base64
2
 
3
  from modal_backend.contracts import (
4
  AuthError,
 
5
  decode_image_payload,
6
  ensure_authorized,
7
  normalize_text_payload,
@@ -39,6 +40,14 @@ def test_decode_image_payload_accepts_images_list():
39
  assert decode_image_payload({"images": [encoded]}) == b"fake-image-bytes"
40
 
41
 
 
 
 
 
 
 
 
 
42
  def test_response_payload_uses_existing_client_shape():
43
  assert response_payload("hello") == {"response": "hello"}
44
 
 
2
 
3
  from modal_backend.contracts import (
4
  AuthError,
5
+ decode_audio_payload,
6
  decode_image_payload,
7
  ensure_authorized,
8
  normalize_text_payload,
 
40
  assert decode_image_payload({"images": [encoded]}) == b"fake-image-bytes"
41
 
42
 
43
+ def test_decode_audio_payload_accepts_audio_key():
44
+ encoded = base64.b64encode(b"fake-audio-bytes").decode("ascii")
45
+ audio_bytes, filename = decode_audio_payload({"audio": encoded, "filename": "dream.wav"})
46
+
47
+ assert audio_bytes == b"fake-audio-bytes"
48
+ assert filename == "dream.wav"
49
+
50
+
51
  def test_response_payload_uses_existing_client_shape():
52
  assert response_payload("hello") == {"response": "hello"}
53
 
tests/test_ui_actions.py CHANGED
@@ -22,6 +22,16 @@ def test_runtime_settings_are_collapsed_for_public_flow():
22
  assert 'gr.Accordion("Advanced", open=False' in source
23
 
24
 
 
 
 
 
 
 
 
 
 
 
25
  def test_processing_note_is_story_copy_not_backend_jargon():
26
  lowered = PROCESSING_NOTE.lower()
27
 
 
22
  assert 'gr.Accordion("Advanced", open=False' in source
23
 
24
 
25
+ def test_voice_input_uses_gradio_audio_file_for_modal_asr():
26
+ source = inspect.getsource(ui_app.build_demo)
27
+
28
+ assert "audio_input = gr.Audio(" in source
29
+ assert 'sources=["microphone", "upload"]' in source
30
+ assert 'type="filepath"' in source
31
+ assert "audio_input = gr.State(None)" not in source
32
+ assert 'value=DEFAULT_ASR_BACKEND' in source
33
+
34
+
35
  def test_processing_note_is_story_copy_not_backend_jargon():
36
  lowered = PROCESSING_NOTE.lower()
37