feat: Add provider selection modal and enhance voice profile handling for Supertonic integration

This commit is contained in:
JB
2025-12-20 07:12:19 -08:00
parent 77eb58bdff
commit 888c737293
5 changed files with 375 additions and 42 deletions
+174 -24
View File
@@ -42,6 +42,7 @@ from abogen.utils import (
)
from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
@@ -67,6 +68,58 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
return str(fallback or "").strip() or "M1"
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
raw = str(value or "").strip()
if not raw or ":" not in raw:
return None, raw
prefix, remainder = raw.split(":", 1)
prefix = prefix.strip().lower()
if prefix not in {"speaker", "profile"}:
return None, raw
name = remainder.strip()
return (name or None), raw
def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
voices = entry.get("voices") or []
if not voices:
return ""
total = 0.0
parts: list[tuple[str, float]] = []
for item in voices:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
name = str(item[0] or "").strip()
try:
weight = float(item[1])
except (TypeError, ValueError):
continue
if not name or weight <= 0:
continue
parts.append((name, weight))
total += weight
if total <= 0 or not parts:
return ""
def _format_weight(value: float) -> str:
normalized = value / total if total else 0.0
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
return "+".join(f"{name}*{_format_weight(weight)}" for name, weight in parts)
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
raw = str(value or "").strip()
if not raw:
return fallback
upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES:
return "supertonic"
if "*" in raw or "+" in raw:
return "kokoro"
return fallback
class _JobCancelled(Exception):
"""Raised internally to abort a conversion when the client cancels."""
@@ -1380,14 +1433,76 @@ def run_conversion_job(job: Job) -> None:
audio_output_path: Optional[Path] = None
extraction: Optional[Any] = None
pipeline: Any = None
pipelines: Dict[str, Any] = {}
kokoro_cache_ready = False
normalized_profiles: Dict[str, Dict[str, Any]] = {}
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
active_chapter_configs: List[Dict[str, Any]] = []
usage_counter: Dict[str, int] = defaultdict(int)
override_token_map: Dict[str, str] = {}
try:
pipeline = _load_pipeline(job)
if getattr(job, "tts_provider", "kokoro") == "kokoro":
_initialize_voice_cache(job)
# Load saved speakers once so we can resolve speaker: references during conversion.
try:
profiles = load_profiles()
except Exception:
profiles = {}
for name, entry in (profiles or {}).items():
normalized = normalize_profile_entry(entry)
if normalized:
normalized_profiles[str(name)] = normalized
def get_pipeline(provider: str) -> Any:
nonlocal kokoro_cache_ready
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
if provider_norm not in {"kokoro", "supertonic"}:
provider_norm = "kokoro"
existing = pipelines.get(provider_norm)
if existing is not None:
return existing
if provider_norm == "supertonic":
pipelines[provider_norm] = SupertonicPipeline(
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
)
return pipelines[provider_norm]
# Kokoro
cfg = load_config()
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
device = "cpu"
if not disable_gpu:
device = _select_device()
_np, KPipeline = load_numpy_kpipeline()
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
if not kokoro_cache_ready:
_initialize_voice_cache(job)
kokoro_cache_ready = True
return pipelines[provider_norm]
def resolve_voice_target(raw_spec: str) -> tuple[str, str, Optional[float], Optional[int]]:
"""Return (provider, voice_spec, speed_override, steps_override)."""
spec = str(raw_spec or "").strip()
speaker_name, _ = _split_speaker_reference(spec)
if speaker_name and speaker_name in normalized_profiles:
entry = normalized_profiles[speaker_name]
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
if provider == "supertonic":
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5)
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
formula = _formula_from_kokoro_entry(entry)
return "kokoro", formula or spec, None, None
fallback_provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
inferred = _infer_provider_from_spec(spec, fallback=fallback_provider)
if inferred == "supertonic":
return "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None
return "kokoro", spec, None, None
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
@@ -1507,9 +1622,10 @@ def run_conversion_job(job: Job) -> None:
base_voice_spec = _job_voice_fallback(job)
voice_cache: Dict[str, Any] = {}
if getattr(job, "tts_provider", "kokoro") == "kokoro":
if base_voice_spec and "*" not in base_voice_spec:
voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu)
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
kokoro_pipeline = get_pipeline("kokoro")
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
processed_chars = 0
subtitle_index = 1
current_time = 0.0
@@ -1538,6 +1654,9 @@ def run_conversion_job(job: Job) -> None:
chapter_sink: Optional[AudioSink],
preview_prefix: Optional[str] = None,
split_pattern: Optional[str] = SPLIT_PATTERN,
tts_provider: Optional[str] = None,
speed_override: Optional[float] = None,
supertonic_steps_override: Optional[int] = None,
) -> int:
nonlocal processed_chars, subtitle_index, current_time
source_text = str(text or "")
@@ -1560,21 +1679,23 @@ def run_conversion_job(job: Job) -> None:
raise
local_segments = 0
provider = getattr(job, "tts_provider", "kokoro")
provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
if provider == "supertonic":
supertonic_pipeline = get_pipeline("supertonic")
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
segment_iter = pipeline(
segment_iter = supertonic_pipeline(
normalized,
voice=voice_name,
speed=job.speed,
speed=float(speed_override if speed_override is not None else job.speed),
split_pattern=split_pattern,
total_steps=getattr(job, "supertonic_total_steps", 5),
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
)
else:
segment_iter = pipeline(
kokoro_pipeline = get_pipeline("kokoro")
segment_iter = kokoro_pipeline(
normalized,
voice=voice_choice,
speed=job.speed,
speed=float(speed_override if speed_override is not None else job.speed),
split_pattern=split_pattern,
)
@@ -1655,10 +1776,16 @@ def run_conversion_job(job: Job) -> None:
if not chapter_voice_spec:
chapter_voice_spec = base_voice_spec
voice_choice = voice_cache.get(chapter_voice_spec)
if voice_choice is None:
voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu)
voice_cache[chapter_voice_spec] = voice_choice
chapter_provider, chapter_voice_resolved, chapter_speed, chapter_steps = resolve_voice_target(chapter_voice_spec)
chapter_cache_key = f"{chapter_provider}:{chapter_voice_resolved}" if chapter_voice_resolved else chapter_provider
if chapter_provider == "kokoro":
voice_choice = voice_cache.get(chapter_cache_key)
if voice_choice is None:
kokoro_pipeline = get_pipeline("kokoro")
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
voice_cache[chapter_cache_key] = voice_choice
else:
voice_choice = chapter_voice_resolved
chapter_audio_path: Optional[Path] = None
segments_emitted = 0
@@ -1694,6 +1821,9 @@ def run_conversion_job(job: Job) -> None:
voice_choice=voice_choice,
chapter_sink=chapter_sink,
preview_prefix="Book intro",
tts_provider=chapter_provider,
speed_override=chapter_speed,
supertonic_steps_override=chapter_steps,
)
intro_emitted = True
if intro_segments > 0 and job.chapter_intro_delay > 0:
@@ -1710,6 +1840,9 @@ def run_conversion_job(job: Job) -> None:
chapter_sink=chapter_sink,
preview_prefix=f"Chapter {idx} title",
split_pattern=SPLIT_PATTERN,
tts_provider=chapter_provider,
speed_override=chapter_speed,
supertonic_steps_override=chapter_steps,
)
segments_emitted += heading_segments
if heading_segments > 0 and job.chapter_intro_delay > 0:
@@ -1782,16 +1915,26 @@ def run_conversion_job(job: Job) -> None:
chunk_voice_spec = chapter_voice_spec or base_voice_spec
if chunk_voice_spec == chapter_voice_spec:
chunk_provider = chapter_provider
chunk_voice_resolved = chapter_voice_resolved
chunk_speed_use = chapter_speed
chunk_steps_use = chapter_steps
chunk_voice_choice = voice_choice
else:
chunk_voice_choice = voice_cache.get(chunk_voice_spec)
if chunk_voice_choice is None:
chunk_voice_choice = _resolve_voice(
pipeline,
chunk_voice_spec,
job.use_gpu,
)
voice_cache[chunk_voice_spec] = chunk_voice_choice
chunk_provider, chunk_voice_resolved, chunk_speed_use, chunk_steps_use = resolve_voice_target(chunk_voice_spec)
chunk_cache_key = f"{chunk_provider}:{chunk_voice_resolved}" if chunk_voice_resolved else chunk_provider
if chunk_provider == "kokoro":
chunk_voice_choice = voice_cache.get(chunk_cache_key)
if chunk_voice_choice is None:
kokoro_pipeline = get_pipeline("kokoro")
chunk_voice_choice = _resolve_voice(
kokoro_pipeline,
chunk_voice_resolved,
job.use_gpu,
)
voice_cache[chunk_cache_key] = chunk_voice_choice
else:
chunk_voice_choice = chunk_voice_resolved
chunk_start = current_time
emitted = emit_text(
@@ -1799,6 +1942,9 @@ def run_conversion_job(job: Job) -> None:
voice_choice=chunk_voice_choice,
chapter_sink=chapter_sink,
preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}",
tts_provider=chunk_provider,
speed_override=chunk_speed_use,
supertonic_steps_override=chunk_steps_use,
)
if emitted <= 0:
continue
@@ -1851,6 +1997,9 @@ def run_conversion_job(job: Job) -> None:
chapter_text,
voice_choice=voice_choice,
chapter_sink=chapter_sink,
tts_provider=chapter_provider,
speed_override=chapter_speed,
supertonic_steps_override=chapter_steps,
)
if emitted > 0:
segments_emitted += emitted
@@ -2093,6 +2242,7 @@ def run_conversion_job(job: Job) -> None:
# Explicitly release the pipeline and force garbage collection to prevent
# memory accumulation in the worker process, which can lead to host lockups.
pipelines.clear()
pipeline = None
gc.collect()
try:
+6 -1
View File
@@ -166,7 +166,12 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
resolved_provider = provider or "kokoro"
profiles = load_profiles()
if profile_name:
if resolved_provider == "supertonic" and not profile_name:
voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
# Allow per-speaker overrides via payload.
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
elif profile_name:
entry = profiles.get(profile_name)
normalized_entry = normalize_profile_entry(entry)
if not normalized_entry:
+42 -9
View File
@@ -6,20 +6,47 @@ import soundfile as sf
from flask import current_app, send_file
from flask.typing import ResponseReturnValue
from abogen.utils import load_numpy_kpipeline
from abogen.voice_formulas import get_new_voice
from abogen.web.conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
from abogen.kokoro_text_normalization import normalize_for_pipeline
SPLIT_PATTERN = r"\n+"
SAMPLE_RATE = 24000
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
_preview_pipeline_lock = threading.Lock()
def _select_device() -> str:
import platform
system = platform.system()
if system == "Darwin" and platform.processor() == "arm":
return "mps"
return "cuda"
def _to_float32(audio_segment) -> np.ndarray:
if audio_segment is None:
return np.zeros(0, dtype="float32")
tensor = audio_segment
if hasattr(tensor, "detach"):
tensor = tensor.detach()
if hasattr(tensor, "cpu"):
try:
tensor = tensor.cpu()
except Exception:
pass
if hasattr(tensor, "numpy"):
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
return np.asarray(tensor, dtype="float32").reshape(-1)
def get_preview_pipeline(language: str, device: str) -> Any:
key = (language, device)
with _preview_pipeline_lock:
pipeline = _preview_pipelines.get(key)
if pipeline is not None:
return pipeline
from abogen.utils import load_numpy_kpipeline
_, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline
@@ -40,11 +67,15 @@ def generate_preview_audio(
provider = (tts_provider or "kokoro").strip().lower()
try:
normalized_text = normalize_for_pipeline(text)
except Exception:
current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = text
normalized_text = text
if provider != "supertonic":
try:
from abogen.kokoro_text_normalization import normalize_for_pipeline
normalized_text = normalize_for_pipeline(text)
except Exception:
current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = text
if provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline
@@ -72,6 +103,8 @@ def generate_preview_audio(
voice_choice: Any = voice_spec
if voice_spec and "*" in voice_spec:
from abogen.voice_formulas import get_new_voice
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
segments = pipeline(
+134 -8
View File
@@ -24,6 +24,7 @@ const setupVoiceMixer = () => {
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
const nameInput = document.getElementById("profile-name");
const languageSelect = document.getElementById("profile-language");
const languageField = app.querySelector(".voice-editor__language");
const providerSelect = document.getElementById("profile-provider");
const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]');
const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]');
@@ -40,6 +41,13 @@ const setupVoiceMixer = () => {
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
const providerPickerModal = document.querySelector('[data-role="provider-picker-modal"]');
const providerPickerOverlay = document.querySelector('[data-role="provider-picker-overlay"]');
const providerPickerClose = document.querySelector('[data-role="provider-picker-close"]');
const providerPickerCancel = document.querySelector('[data-role="provider-picker-cancel"]');
const providerPickerConfirm = document.querySelector('[data-role="provider-picker-confirm"]');
const providerPickerOptions = document.querySelector('[data-role="provider-picker-options"]');
if (previewBtn && !previewBtn.dataset.label) {
previewBtn.dataset.label = previewBtn.textContent.trim();
}
@@ -143,12 +151,123 @@ const setupVoiceMixer = () => {
return candidate === "supertonic" ? "supertonic" : "kokoro";
};
const getProviderCatalog = () => {
if (!providerSelect) {
return [
{ id: "kokoro", label: "Kokoro" },
{ id: "supertonic", label: "Supertonic" },
];
}
return Array.from(providerSelect.options || []).map((option) => ({
id: normalizeProvider(option.value),
label: option.textContent?.trim() || option.value,
}));
};
const providerDescription = (providerId) => {
const provider = normalizeProvider(providerId);
if (provider === "supertonic") {
return "Voice selection + quality/speed per speaker.";
}
return "Voice mixing supported via the Kokoro mixer.";
};
const openProviderPicker = (defaultProvider = "kokoro") => {
if (!providerPickerModal || !providerPickerOptions || !providerPickerConfirm) {
return Promise.resolve(normalizeProvider(defaultProvider));
}
providerPickerOptions.innerHTML = "";
const catalog = getProviderCatalog();
const normalizedDefault = normalizeProvider(defaultProvider);
catalog.forEach((item) => {
const label = document.createElement("label");
label.className = "toggle-pill";
const input = document.createElement("input");
input.type = "radio";
input.name = "provider-picker";
input.value = item.id;
input.checked = item.id === normalizedDefault;
const span = document.createElement("span");
const title = document.createElement("strong");
title.textContent = item.label;
const detail = document.createElement("span");
detail.className = "muted";
detail.textContent = `${providerDescription(item.id)}`;
span.appendChild(title);
span.appendChild(detail);
label.appendChild(input);
label.appendChild(span);
providerPickerOptions.appendChild(label);
});
const selectedRadio = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
providerPickerConfirm.disabled = !selectedRadio;
return new Promise((resolve) => {
let resolved = false;
const teardown = () => {
providerPickerModal.hidden = true;
document.removeEventListener("keydown", onKeydown);
providerPickerOptions.removeEventListener("change", onChange);
providerPickerOverlay?.removeEventListener("click", onCancel);
providerPickerClose?.removeEventListener("click", onCancel);
providerPickerCancel?.removeEventListener("click", onCancel);
providerPickerConfirm?.removeEventListener("click", onConfirm);
};
const finish = (value) => {
if (resolved) return;
resolved = true;
teardown();
resolve(value);
};
const onCancel = () => finish(null);
const onConfirm = () => {
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
finish(selected ? normalizeProvider(selected.value) : null);
};
const onChange = () => {
const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked');
providerPickerConfirm.disabled = !selected;
};
const onKeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
};
providerPickerModal.hidden = false;
document.addEventListener("keydown", onKeydown);
providerPickerOptions.addEventListener("change", onChange);
providerPickerOverlay?.addEventListener("click", onCancel);
providerPickerClose?.addEventListener("click", onCancel);
providerPickerCancel?.addEventListener("click", onCancel);
providerPickerConfirm?.addEventListener("click", onConfirm);
const focusTarget = providerPickerOptions.querySelector('input[name="provider-picker"]:checked')
|| providerPickerOptions.querySelector('input[name="provider-picker"]');
if (focusTarget instanceof HTMLElement) {
focusTarget.focus();
}
});
};
const applyProviderToUI = () => {
const provider = normalizeProvider(state.draft.provider);
const isSupertonic = provider === "supertonic";
if (providerSelect) {
providerSelect.value = provider;
}
if (languageField) {
languageField.hidden = isSupertonic;
}
if (kokoroMixerEl) {
kokoroMixerEl.hidden = isSupertonic;
}
@@ -588,13 +707,12 @@ const setupVoiceMixer = () => {
setStatus(`Loaded speaker “${name}”.`, "info", 2500);
};
const createNewProfile = () => {
const isSupertonic = window.confirm("Create a Supertonic speaker?\n\nOK = Supertonic\nCancel = Kokoro");
const startNewProfile = (provider = "kokoro") => {
state.selectedProfile = null;
state.originalName = null;
state.draft = {
name: "",
provider: isSupertonic ? "supertonic" : "kokoro",
provider: normalizeProvider(provider),
language: languageSelect ? languageSelect.value || "a" : "a",
voices: new Map(),
supertonic: {
@@ -608,6 +726,15 @@ const setupVoiceMixer = () => {
loadSampleText();
};
const requestNewProfile = async () => {
const chosen = await openProviderPicker(normalizeProvider(state.draft.provider));
if (!chosen) {
return;
}
startNewProfile(chosen);
setStatus("New speaker ready.", "info");
};
const refreshProfiles = (nextProfiles, selectedName = null) => {
profiles = nextProfiles || {};
renderProfileList();
@@ -620,7 +747,7 @@ const setupVoiceMixer = () => {
if (names.length) {
selectProfile(names[0]);
} else {
createNewProfile();
startNewProfile("kokoro");
}
}
updateActionButtons();
@@ -657,7 +784,7 @@ const setupVoiceMixer = () => {
name,
originalName: state.originalName,
provider: normalizeProvider(state.draft.provider),
language: languageSelect ? languageSelect.value : "a",
language: normalizeProvider(state.draft.provider) === "kokoro" ? (languageSelect ? languageSelect.value : "a") : "a",
voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [],
voice: state.draft.supertonic?.voice,
total_steps: state.draft.supertonic?.total_steps,
@@ -964,8 +1091,7 @@ const setupVoiceMixer = () => {
const action = target.dataset.action;
if (!action) return;
if (action === "new-profile") {
createNewProfile();
setStatus("New profile ready.", "info");
requestNewProfile();
} else if (action === "import-profiles") {
importInput?.click();
} else if (action === "export-profiles") {
@@ -1023,7 +1149,7 @@ const setupVoiceMixer = () => {
renderAvailableVoices();
renderProfileList();
createNewProfile();
startNewProfile("kokoro");
if (Object.keys(profiles).length) {
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
+19
View File
@@ -146,6 +146,25 @@
</section>
</div>
<input id="voice-import-input" type="file" accept="application/json" hidden>
<div class="modal" data-role="provider-picker-modal" hidden>
<div class="modal__overlay" data-role="provider-picker-overlay" tabindex="-1"></div>
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="provider-picker-title">
<header class="modal__header">
<p class="modal__eyebrow">Speaker Studio</p>
<h2 class="modal__title" id="provider-picker-title">Choose a TTS provider</h2>
<button type="button" class="button button--ghost button--small" data-role="provider-picker-close" aria-label="Close provider picker">Close</button>
</header>
<div class="modal__body">
<p class="hint">Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.</p>
<div class="field field--choices" data-role="provider-picker-options"></div>
</div>
<footer class="modal__footer">
<button type="button" class="button button--ghost" data-role="provider-picker-cancel">Cancel</button>
<button type="button" class="button" data-role="provider-picker-confirm">Continue</button>
</footer>
</div>
</div>
</section>
{% endblock %}