diff --git a/README.md b/README.md
index f5033b7..379aab2 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Your voice, on script.
-Double Chin is a local voice-cloning application. Give it a few minutes of someone's voice and a text script, and it reads the script aloud in that voice — entirely on your machine. No cloud, no account, no audio leaving the laptop. One command opens the app: pick a voice, paste a script, hit Generate, and watch it synthesize chunk by chunk with a live speaker-similarity verdict on every take.
+Double Chin is a local voice-cloning application. Give it a few minutes of someone's voice and a text script, and it reads the script aloud in that voice — entirely on your machine. No cloud, no account, no audio leaving the laptop. One command opens a desktop window built around your own voice: write a script, hit Generate, and watch it synthesize chunk by chunk with a speaker-similarity check on every take.

@@ -40,9 +40,18 @@ uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e ".[dev]"
source .venv/bin/activate
double-chin doctor # checks device, deps, disk — everything should PASS
-double-chin studio # starts http://127.0.0.1:8787 and opens your browser
+double-chin app # opens the desktop window
```
+`double-chin app` is the desktop shell. `double-chin studio` serves the same
+interface at http://127.0.0.1:8787 in a browser, and `bash packaging/build_app.sh`
+builds `dist/Double Chin.app` so it launches from the Dock like any other app.
+
+The window is built around a single voice — the one you enrolled. Delivery
+controls, prosody marks and the environment report are there when you want them
+and out of the way when you don't; enrolling or switching voices lives in
+Settings.
+
## Clone your own voice
Record the [79-take corpus](recording-scripts/) (~50 minutes), then:
diff --git a/src/double_chin/desktop.py b/src/double_chin/desktop.py
index 73c7cfa..dbdcea9 100644
--- a/src/double_chin/desktop.py
+++ b/src/double_chin/desktop.py
@@ -11,6 +11,7 @@
from __future__ import annotations
import socket
+import subprocess
import sys
import threading
import time
@@ -18,10 +19,43 @@
import uvicorn
_PREFERRED_PORT = 8787
+# The window paints its background colour before the page has loaded. Left at
+# pywebview's white default that is a bright flash on the way into a dark
+# interface, so both values below mirror `--bg-grouped` in
+# studio/static/tokens.css — a test asserts they stay in step.
+_LAUNCH_COLOR_LIGHT = "#F2F2F7"
+_LAUNCH_COLOR_DARK = "#1C1C1E"
+_APPEARANCE_TIMEOUT_SECONDS = 2.0
_STARTUP_TIMEOUT_SECONDS = 15.0
_STARTUP_POLL_SECONDS = 0.05
+def _macos_prefers_dark() -> bool:
+ """True when macOS is in dark mode.
+
+ `defaults read -g AppleInterfaceStyle` prints "Dark" in dark mode and exits
+ non-zero in light mode (the key is absent), so any failure means light.
+ """
+ if sys.platform != "darwin":
+ return False
+ try:
+ result = subprocess.run(
+ ["defaults", "read", "-g", "AppleInterfaceStyle"],
+ capture_output=True,
+ text=True,
+ timeout=_APPEARANCE_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return result.stdout.strip() == "Dark"
+
+
+def _launch_background_color(prefers_dark=_macos_prefers_dark) -> str:
+ """The colour the window shows in the moment before the page paints."""
+ return _LAUNCH_COLOR_DARK if prefers_dark() else _LAUNCH_COLOR_LIGHT
+
+
def _pick_port() -> int:
"""Prefer the well-known Studio port; fall back to any free loopback port."""
for port in (_PREFERRED_PORT, 0):
@@ -114,9 +148,10 @@ def run() -> int:
window = webview.create_window(
"Double Chin",
f"http://127.0.0.1:{port}",
- width=1160,
- height=780,
- min_size=(760, 560),
+ width=820,
+ height=860,
+ min_size=(520, 480),
+ background_color=_launch_background_color(),
)
window.events.closed += lambda: setattr(server, "should_exit", True)
diff --git a/src/double_chin/studio/static/api.js b/src/double_chin/studio/static/api.js
new file mode 100644
index 0000000..4a05e36
--- /dev/null
+++ b/src/double_chin/studio/static/api.js
@@ -0,0 +1,34 @@
+/* Thin wrapper over the Studio HTTP API. No endpoint shapes are invented
+ * here — every path below already exists in studio/app.py. */
+"use strict";
+
+async function request(url, options) {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ let detail = `${response.status} ${response.statusText}`;
+ try {
+ const body = await response.json();
+ if (body.detail) detail = body.detail;
+ } catch (_) { /* error body was not JSON; keep the status line */ }
+ const error = new Error(detail);
+ error.status = response.status;
+ throw error;
+ }
+ return response.json();
+}
+
+export const api = {
+ listVoices: () => request("/api/voices"),
+ enrollVoice: (formData) => request("/api/voices", { method: "POST", body: formData }),
+ createJob: (payload) =>
+ request("/api/jobs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }),
+ listHistory: () => request("/api/history"),
+ doctor: () => request("/api/doctor"),
+ jobEvents: (jobId) => new EventSource(`/api/jobs/${jobId}/events`),
+ audioUrl: (jobId, download = false) =>
+ `/api/audio/${jobId}${download ? "?download=true" : ""}`,
+};
diff --git a/src/double_chin/studio/static/app.js b/src/double_chin/studio/static/app.js
index 33c1f75..a879086 100644
--- a/src/double_chin/studio/static/app.js
+++ b/src/double_chin/studio/static/app.js
@@ -1,130 +1,265 @@
-/* Double Chin frontend. Plain JS, no build step, no external requests. */
+/* Double Chin — single-voice studio.
+ *
+ * The app is built around one enrolled voice: it is resolved once at boot and
+ * never appears as a choice on the main surface. Voice management, enrolment
+ * and environment facts all live in the Settings sheet.
+ */
"use strict";
+import { api } from "./api.js";
+import {
+ estimateAudioSeconds,
+ estimateWallSeconds,
+ formatClock,
+ formatCount,
+ formatDuration,
+ formatWhen,
+ pluralize,
+ verdictTone,
+} from "./format.js";
+
+const DELIVERY_DEFAULTS = Object.freeze({
+ exaggeration: 0.5,
+ cfg: 0.5,
+ temperature: 0.8,
+ rate: 1,
+});
+
+const KNOB_LABELS = Object.freeze({
+ exaggeration: "Expression",
+ cfg: "Adherence",
+ temperature: "Variation",
+ rate: "Rate",
+});
+
+/* Which enrolled voice is "yours". Only ever set from the Settings sheet —
+ * the composing surface never asks. */
+const VOICE_KEY = "double-chin.voice";
+
const $ = (id) => document.getElementById(id);
+function readStoredVoice() {
+ try {
+ return localStorage.getItem(VOICE_KEY);
+ } catch (_) {
+ return null; // private window, or site data blocked
+ }
+}
+
+function storeVoice(name) {
+ try {
+ localStorage.setItem(VOICE_KEY, name);
+ } catch (_) { /* the choice simply does not persist */ }
+}
+
const state = {
- voices: [],
+ voice: null,
jobRunning: false,
eventSource: null,
timerHandle: null,
jobStartedAt: null,
};
-/* ---------- bootstrap ---------- */
+/* ---------- boot ---------- */
async function init() {
wireControls();
- await Promise.all([refreshVoices(), refreshDoctor(), refreshHistory()]);
+ syncDelivery();
+ updateScriptStats();
+ await Promise.allSettled([refreshVoice(), refreshEnvironment(), refreshHistory()]);
}
-async function fetchJson(url, options) {
- const response = await fetch(url, options);
- if (!response.ok) {
- let detail = `${response.status} ${response.statusText}`;
- try {
- const body = await response.json();
- if (body.detail) detail = body.detail;
- } catch (_) { /* non-JSON error body */ }
- throw new Error(detail);
- }
- return response.json();
+/* ---------- voice ---------- */
+
+async function refreshVoice(preferName) {
+ let voices = [];
+ try {
+ voices = await api.listVoices();
+ } catch (_) { /* leave the app in its no-voice state */ }
+
+ const wanted = preferName ?? readStoredVoice();
+ state.voice = voices.find((voice) => voice.name === wanted) ?? voices[0] ?? null;
+ if (state.voice) storeVoice(state.voice.name);
+
+ $("toolbar-voice").textContent = state.voice ? state.voice.name : "No voice";
+ renderVoicePicker(voices);
+ renderVoiceCard();
+ renderIdle();
+ updateGenerateEnabled();
}
-/* ---------- voices ---------- */
-
-async function refreshVoices(selectName) {
- state.voices = await fetchJson("/api/voices");
- const select = $("voice");
- select.innerHTML = "";
- for (const voice of state.voices) {
- const option = document.createElement("option");
- option.value = voice.name;
- option.textContent = `${voice.name} · ${voice.duration_seconds}s`;
- select.appendChild(option);
+/** Shown only when more than one voice is enrolled; the main surface never has it. */
+function renderVoicePicker(voices) {
+ const wrapper = $("voice-switch");
+ const select = $("voice-select");
+ wrapper.hidden = voices.length < 2;
+ if (wrapper.hidden) return;
+
+ select.textContent = "";
+ for (const voice of voices) {
+ const option = el("option", { value: voice.name, text: voice.name });
+ if (voice.name === state.voice?.name) option.selected = true;
+ select.append(option);
}
- if (selectName) select.value = selectName;
- updateVoiceHint();
- updateGenerateEnabled();
}
-function updateVoiceHint() {
- const voice = state.voices.find((v) => v.name === $("voice").value);
+function renderVoiceCard() {
+ const card = $("voice-card");
const hint = $("voice-hint");
- if (!voice) {
- hint.textContent = "no voices yet — enroll one from a few recordings";
+ card.textContent = "";
+
+ if (!state.voice) {
+ card.append(el("div", { class: "voice-name", text: "No voice yet" }));
+ hint.textContent =
+ "Enrol a voice below from a few of your own recordings, then close this sheet.";
return;
}
- hint.textContent = voice.has_holdout
- ? "verifies against a held-out clip the model never conditions on"
- : "no holdout clip — verification compares against the reference itself";
+
+ const name = el("div", { class: "voice-name", text: state.voice.name });
+ const meta = el("div", {
+ class: "voice-meta numeric",
+ text: `${formatDuration(state.voice.duration_seconds)} reference`,
+ });
+ card.append(name, meta);
+
+ hint.textContent = state.voice.has_holdout
+ ? "Takes are checked against a held-out clip the model never conditions on."
+ : "No held-out clip, so takes are checked against the reference itself.";
}
-/* ---------- doctor ---------- */
+function renderIdle() {
+ const idle = $("idle-state");
+ idle.textContent = "";
+ if (!state.voice) {
+ idle.append(
+ document.createTextNode("No voice is enrolled yet. "),
+ el("button", { class: "link", type: "button", text: "Add one in Settings", id: "idle-settings" }),
+ document.createTextNode("."),
+ );
+ $("idle-settings").addEventListener("click", openSettings);
+ return;
+ }
+ idle.textContent =
+ "The first take loads the model, which takes about ten seconds. After that it " +
+ "synthesizes at roughly a fifth of realtime, a chunk at a time.";
+}
-async function refreshDoctor() {
+/* ---------- environment ---------- */
+
+async function refreshEnvironment() {
+ let doctor;
try {
- const doctor = await fetchJson("/api/doctor");
- const device = doctor.device ? doctor.device.toUpperCase() : "no torch";
- const weights = doctor.weights_cached ? "weights cached" : "first run downloads weights";
- $("env").innerHTML = `${device} · ${weights}`;
- $("version").textContent = `double-chin ${doctor.version}`;
+ doctor = await api.doctor();
} catch (_) {
- $("env").textContent = "";
+ $("engine-pill").textContent = "";
+ return;
+ }
+
+ const device = doctor.device ? doctor.device.toUpperCase() : "no torch";
+ $("engine-pill").textContent = "";
+ $("engine-pill").append(
+ el("b", { text: device }),
+ document.createTextNode(doctor.weights_cached ? " · ready" : " · first run downloads weights"),
+ );
+ $("version").textContent = `v${doctor.version}`;
+
+ const facts = $("env-facts");
+ facts.textContent = "";
+ const rows = [
+ ["Device", device],
+ ["PyTorch", doctor.torch ?? "not installed"],
+ ["Model weights", doctor.weights_cached ? "cached" : "download on first take"],
+ ["Voices", String(doctor.voices)],
+ ["Home", doctor.double_chin_home],
+ ];
+ for (const [term, value] of rows) {
+ facts.append(el("dt", { text: term }), el("dd", { text: value }));
}
}
-/* ---------- script stats ---------- */
+/* ---------- script ---------- */
function updateScriptStats() {
const text = $("script").value;
const chars = text.length;
- $("script-stats").textContent = `${chars.toLocaleString()} chars`;
- if (chars > 0) {
- // Rough planning figures: ~15 chars/s spoken, ~0.2x realtime synthesis.
- const audioSeconds = chars / 15;
- const wallSeconds = audioSeconds / 0.2;
- $("eta").textContent =
- `≈ ${formatSeconds(audioSeconds)} of audio · rough synthesis time ${formatSeconds(wallSeconds)}`;
- } else {
- $("eta").textContent = "";
- }
- updateGenerateEnabled();
-}
-function formatSeconds(total) {
- const seconds = Math.round(total);
- if (seconds < 90) return `${seconds}s`;
- return `${Math.round(seconds / 60)}min`;
+ $("script-stats").textContent = chars === 0
+ ? ""
+ : `${formatCount(chars)} characters · about ${formatDuration(estimateAudioSeconds(text))} of audio`;
+
+ $("eta").textContent = chars === 0
+ ? ""
+ : `Roughly ${formatDuration(estimateWallSeconds(text))} to synthesize`;
+
+ updateGenerateEnabled();
}
function updateGenerateEnabled() {
$("generate").disabled =
- state.jobRunning || !$("voice").value || $("script").value.trim() === "";
+ state.jobRunning || state.voice === null || $("script").value.trim() === "";
+}
+
+/* ---------- delivery ---------- */
+
+function setRangeFill(input) {
+ const min = parseFloat(input.min);
+ const max = parseFloat(input.max);
+ const ratio = (parseFloat(input.value) - min) / (max - min);
+ input.style.setProperty("--fill", `${(ratio * 100).toFixed(2)}%`);
+}
+
+function knobValue(id) {
+ return parseFloat($(id).value);
+}
+
+/** Keeps the range fills, the numeric outputs, and the collapsed summary in step. */
+function syncDelivery() {
+ const changed = [];
+ for (const [id, fallback] of Object.entries(DELIVERY_DEFAULTS)) {
+ const input = $(id);
+ setRangeFill(input);
+ const value = knobValue(id);
+ const suffix = id === "rate" ? "×" : "";
+ $(`${id}-val`).textContent = `${value.toFixed(2)}${suffix}`;
+ if (Math.abs(value - fallback) > 1e-9) {
+ changed.push(`${KNOB_LABELS[id]} ${value.toFixed(2)}${suffix}`);
+ }
+ }
+ if ($("seed").value.trim() !== "") changed.push("fixed seed");
+
+ $("delivery-summary").textContent = changed.length === 0 ? "Default" : changed.join(" · ");
+}
+
+function resetDelivery() {
+ for (const [id, fallback] of Object.entries(DELIVERY_DEFAULTS)) {
+ $(id).value = String(fallback);
+ }
+ $("seed").value = "";
+ syncDelivery();
}
/* ---------- generate ---------- */
async function generate() {
+ if ($("generate").disabled) return;
+
const seedRaw = $("seed").value.trim();
const payload = {
- voice: $("voice").value,
+ voice: state.voice.name,
text: $("script").value,
- exaggeration: parseFloat($("exaggeration").value),
- cfg_weight: parseFloat($("cfg").value),
- temperature: parseFloat($("temperature").value),
- rate: parseFloat($("rate").value),
+ exaggeration: knobValue("exaggeration"),
+ cfg_weight: knobValue("cfg"),
+ temperature: knobValue("temperature"),
+ rate: knobValue("rate"),
seed: seedRaw === "" ? null : parseInt(seedRaw, 10),
};
let job;
try {
- job = await fetchJson("/api/jobs", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
+ job = await api.createJob(payload);
} catch (error) {
+ resetJobCard();
+ setStatus("Failed", "error");
showJobError(error.message);
return;
}
@@ -133,80 +268,89 @@ async function generate() {
followJob(job.job_id);
}
-function beginJobUi() {
- state.jobRunning = true;
- state.jobStartedAt = Date.now();
- updateGenerateEnabled();
+/** Clears the job card of any previous run and brings it forward. */
+function resetJobCard() {
$("idle-state").hidden = true;
$("result").hidden = true;
$("job-state").hidden = false;
$("job-error").hidden = true;
- $("chunk-list").innerHTML = "";
+ $("chunk-list").textContent = "";
+ $("job-timer").textContent = "";
+ $("bar-fill").style.width = "0%";
+}
+
+function beginJobUi() {
+ state.jobRunning = true;
+ state.jobStartedAt = Date.now();
+ updateGenerateEnabled();
+
+ resetJobCard();
$("bar-fill").style.width = "2%";
- setStatus("model loading…", "");
+ setStatus("Loading the model…", "");
+
state.timerHandle = setInterval(() => {
- const elapsed = Math.round((Date.now() - state.jobStartedAt) / 1000);
- $("job-timer").textContent = `${elapsed}s`;
+ $("job-timer").textContent = formatClock((Date.now() - state.jobStartedAt) / 1000);
}, 1000);
}
-function setStatus(text, cls) {
+function setStatus(text, tone) {
const status = $("job-status");
status.textContent = text;
- status.className = `status ${cls}`;
+ status.className = `status ${tone}`;
}
function followJob(jobId) {
- const source = new EventSource(`/api/jobs/${jobId}/events`);
+ const source = api.jobEvents(jobId);
state.eventSource = source;
source.addEventListener("progress", (event) => {
const data = JSON.parse(event.data);
- setStatus(`synthesizing chunk ${data.chunk}/${data.total}`, "");
+ setStatus(`Synthesizing ${data.chunk} of ${data.total}`, "");
renderChunk(data);
- $("bar-fill").style.width = `${((data.chunk - 1) / data.total) * 100}%`;
+ $("bar-fill").style.width = `${((data.chunk - 0.5) / data.total) * 100}%`;
});
source.addEventListener("verifying", (event) => {
const data = JSON.parse(event.data);
markAllChunksDone();
$("bar-fill").style.width = "96%";
- setStatus(`verifying speaker similarity (vs ${data.against})…`, "");
+ setStatus(`Checking the voice against ${data.against}…`, "");
});
source.addEventListener("done", (event) => {
const record = JSON.parse(event.data);
finishJob();
- setStatus("done", "done");
+ setStatus("Done", "done");
$("bar-fill").style.width = "100%";
showResult(record);
refreshHistory();
});
source.addEventListener("error", (event) => {
- // Fired both for server-sent error events (with data) and transport
- // errors (without); a closed stream after "done" also lands here.
+ // Fires both for server-sent error events (which carry data) and for
+ // transport errors (which do not); a stream closed after "done" also
+ // lands here, hence the jobRunning guard.
if (event.data) {
const data = JSON.parse(event.data);
finishJob();
- setStatus("failed", "error");
+ setStatus("Failed", "error");
showJobError(data.message);
} else if (state.jobRunning) {
finishJob();
- setStatus("connection lost", "error");
- showJobError("Lost the progress stream. The job may still be running — check History shortly.");
+ setStatus("Lost the connection", "error");
+ showJobError(
+ "The progress stream dropped. The take may still be running — check Recent in a moment.",
+ );
}
});
}
function renderChunk(data) {
- const list = $("chunk-list");
markAllChunksDone();
- const item = document.createElement("li");
- item.className = "active";
- item.innerHTML = `${data.chunk}/${data.total} `;
- item.appendChild(document.createTextNode(data.text));
- list.appendChild(item);
+ const item = el("li", { class: "active" });
+ item.append(el("span", { class: "idx", text: `${data.chunk}/${data.total}` }));
+ item.append(document.createTextNode(data.text));
+ $("chunk-list").append(item);
item.scrollIntoView({ block: "nearest" });
}
@@ -233,104 +377,121 @@ function showJobError(message) {
/* ---------- result ---------- */
-function verdictClass(verdict) {
- if (!verdict) return "plain";
- if (verdict.includes("strong")) return "ok";
- if (verdict === "match") return "ok";
- if (verdict === "borderline") return "warn";
- return "err";
-}
-
function showResult(record) {
+ $("idle-state").hidden = true;
+ $("job-state").hidden = true;
$("result").hidden = false;
+
const chip = $("verdict-chip");
if (record.similarity != null) {
- chip.textContent = `${record.similarity.toFixed(3)} · ${record.verdict} (vs ${record.compared_against})`;
- chip.className = `chip ${verdictClass(record.verdict)}`;
+ chip.textContent = `${record.verdict} · ${record.similarity.toFixed(3)}`;
+ chip.className = `verdict ${verdictTone(record.verdict)}`;
} else {
- chip.textContent = "not verified";
- chip.className = "chip plain";
+ chip.textContent = "Not verified";
+ chip.className = "verdict plain";
}
+
$("result-meta").textContent =
- `${record.audio_seconds}s audio · ${record.chunk_count} chunks · ` +
- `${record.wall_seconds}s on ${record.device}`;
+ `${formatDuration(record.audio_seconds)} of audio · ${pluralize(record.chunk_count, "chunk")} · ` +
+ `${formatDuration(record.wall_seconds)} on ${record.device}`;
+
const player = $("player");
- player.src = `/api/audio/${record.id}`;
- $("download").href = `/api/audio/${record.id}?download=true`;
- player.play().catch(() => { /* autoplay may be blocked; the controls remain */ });
+ player.src = api.audioUrl(record.id);
+ $("download").href = api.audioUrl(record.id, true);
+ player.play().catch(() => { /* autoplay may be blocked; the controls still work */ });
}
/* ---------- history ---------- */
async function refreshHistory() {
- const records = await fetchJson("/api/history");
+ let records;
+ try {
+ records = await api.listHistory();
+ } catch (_) {
+ return;
+ }
+
const container = $("history");
- container.innerHTML = "";
+ container.textContent = "";
+
if (records.length === 0) {
- container.innerHTML = '
nothing generated yet
';
+ container.append(el("p", { class: "history-empty", text: "Nothing generated yet." }));
return;
}
+
for (const record of records) {
- const item = document.createElement("div");
- item.className = "item";
- item.setAttribute("role", "button");
- item.tabIndex = 0;
-
- const text = document.createElement("div");
- text.className = "txt";
- text.textContent = record.text_preview;
-
- const score = document.createElement("div");
- score.className = "score";
- if (record.similarity != null) {
- score.textContent = record.similarity.toFixed(3);
- score.classList.add(verdictClass(record.verdict));
- }
+ container.append(historyRow(record));
+ }
+}
- const sub = document.createElement("div");
- sub.className = "sub";
- const when = new Date(record.created).toLocaleString();
- sub.textContent =
- `${record.voice} · ${record.audio_seconds}s · ${when}` +
- (record.audio_available ? "" : " · audio missing");
-
- item.append(text, score, sub);
- if (record.audio_available) {
- const play = () => {
- $("result").hidden = false;
- showResult(record);
- };
- item.addEventListener("click", play);
- item.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") { event.preventDefault(); play(); }
- });
- }
- container.appendChild(item);
+function historyRow(record) {
+ const playable = record.audio_available;
+ const item = el("div", {
+ class: "history-item",
+ role: "button",
+ tabindex: playable ? "0" : "-1",
+ });
+ if (!playable) item.setAttribute("aria-disabled", "true");
+
+ item.append(el("div", { class: "txt", text: record.text_preview }));
+
+ const score = el("div", { class: "score" });
+ if (record.similarity != null) {
+ score.textContent = record.similarity.toFixed(3);
+ score.classList.add(verdictTone(record.verdict));
}
+ item.append(score);
+
+ const parts = [record.voice, formatDuration(record.audio_seconds), formatWhen(record.created)];
+ if (!playable) parts.push("audio missing");
+ item.append(el("div", { class: "sub", text: parts.filter(Boolean).join(" · ") }));
+
+ if (playable) {
+ const play = () => showResult(record);
+ item.addEventListener("click", play);
+ item.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ play();
+ }
+ });
+ }
+ return item;
}
-/* ---------- enroll ---------- */
+/* ---------- settings ---------- */
+
+function openSettings() {
+ const sheet = $("settings");
+ if (!sheet.open) sheet.showModal();
+}
+
+function closeSettings() {
+ const sheet = $("settings");
+ if (sheet.open) sheet.close();
+}
async function enroll(event) {
event.preventDefault();
- const name = $("enroll-name").value.trim();
- const files = $("enroll-files").files;
const status = $("enroll-status");
const formData = new FormData();
- formData.append("name", name);
- for (const file of files) formData.append("files", file);
+ formData.append("name", $("enroll-name").value.trim());
+ formData.append("overwrite", $("enroll-overwrite").checked ? "true" : "false");
+ for (const file of $("enroll-files").files) formData.append("files", file);
$("enroll-btn").disabled = true;
- status.textContent = "processing recordings…";
+ status.textContent = "Processing the recordings…";
try {
- const info = await fetchJson("/api/voices", { method: "POST", body: formData });
- status.textContent =
- `enrolled '${info.name}' (${info.duration_seconds}s reference` +
- (info.has_holdout ? ", holdout reserved)" : ")");
- await refreshVoices(info.name);
+ const info = await api.enrollVoice(formData);
+ status.textContent = info.has_holdout
+ ? `Enrolled ${info.name} with a held-out clip reserved.`
+ : `Enrolled ${info.name}.`;
$("enroll-form").reset();
+ await Promise.all([refreshVoice(info.name), refreshEnvironment()]);
} catch (error) {
- status.textContent = `error: ${error.message}`;
+ status.textContent = error.status === 409
+ ? `${error.message} Tick the replace box to overwrite it.`
+ : error.message;
} finally {
$("enroll-btn").disabled = false;
}
@@ -341,28 +502,53 @@ async function enroll(event) {
function wireControls() {
$("generate").addEventListener("click", generate);
$("script").addEventListener("input", updateScriptStats);
- $("voice").addEventListener("change", updateVoiceHint);
- $("enroll-toggle").addEventListener("click", () => {
- const form = $("enroll-form");
- form.hidden = !form.hidden;
- $("enroll-toggle").setAttribute("aria-expanded", String(!form.hidden));
- });
- $("enroll-form").addEventListener("submit", enroll);
+ for (const id of Object.keys(DELIVERY_DEFAULTS)) {
+ $(id).addEventListener("input", syncDelivery);
+ }
+ $("seed").addEventListener("input", syncDelivery);
+ $("reset-delivery").addEventListener("click", resetDelivery);
$("script-file").addEventListener("change", async () => {
const file = $("script-file").files[0];
- if (file) {
- $("script").value = await file.text();
- updateScriptStats();
- }
+ if (!file) return;
+ $("script").value = await file.text();
+ $("script-file").value = "";
+ updateScriptStats();
});
- for (const id of ["exaggeration", "cfg", "temperature", "rate"]) {
- $(id).addEventListener("input", () => {
- $(`${id}-val`).textContent = parseFloat($(id).value).toFixed(2);
- });
+ $("voice-select").addEventListener("change", (event) => {
+ storeVoice(event.target.value);
+ refreshVoice(event.target.value);
+ });
+
+ $("settings-open").addEventListener("click", openSettings);
+ $("settings-close").addEventListener("click", closeSettings);
+ $("enroll-form").addEventListener("submit", enroll);
+
+ // Clicking the dimmed area outside the sheet dismisses it, as a sheet should.
+ $("settings").addEventListener("click", (event) => {
+ if (event.target === $("settings")) closeSettings();
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") return;
+ if ($("settings").open) return;
+ event.preventDefault();
+ generate();
+ });
+}
+
+/* ---------- tiny DOM helper ---------- */
+
+function el(tag, options = {}) {
+ const node = document.createElement(tag);
+ const { text, ...attributes } = options;
+ for (const [name, value] of Object.entries(attributes)) {
+ node.setAttribute(name, value);
}
+ if (text !== undefined) node.textContent = text;
+ return node;
}
init();
diff --git a/src/double_chin/studio/static/fonts/JetBrainsMono-Medium.ttf b/src/double_chin/studio/static/fonts/JetBrainsMono-Medium.ttf
deleted file mode 100644
index dc2e5d0..0000000
Binary files a/src/double_chin/studio/static/fonts/JetBrainsMono-Medium.ttf and /dev/null differ
diff --git a/src/double_chin/studio/static/fonts/JetBrainsMono-Regular.ttf b/src/double_chin/studio/static/fonts/JetBrainsMono-Regular.ttf
deleted file mode 100644
index 711830e..0000000
Binary files a/src/double_chin/studio/static/fonts/JetBrainsMono-Regular.ttf and /dev/null differ
diff --git a/src/double_chin/studio/static/format.js b/src/double_chin/studio/static/format.js
new file mode 100644
index 0000000..9589f67
--- /dev/null
+++ b/src/double_chin/studio/static/format.js
@@ -0,0 +1,57 @@
+/* Presentation helpers. Pure functions, no DOM. */
+"use strict";
+
+/** Rough planning figures used for the pre-flight estimate. */
+const CHARS_PER_SPOKEN_SECOND = 15;
+const SYNTHESIS_REALTIME_FACTOR = 0.2;
+
+export function estimateAudioSeconds(text) {
+ return text.length / CHARS_PER_SPOKEN_SECOND;
+}
+
+export function estimateWallSeconds(text) {
+ return estimateAudioSeconds(text) / SYNTHESIS_REALTIME_FACTOR;
+}
+
+/** "8s" / "3 min" — coarse on purpose, these are estimates. */
+export function formatDuration(totalSeconds) {
+ const seconds = Math.max(0, Math.round(totalSeconds));
+ if (seconds < 90) return `${seconds}s`;
+ return `${Math.round(seconds / 60)} min`;
+}
+
+/** "0:42" — for elapsed time, where the seconds matter. */
+export function formatClock(totalSeconds) {
+ const seconds = Math.max(0, Math.round(totalSeconds));
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
+}
+
+/** "1 chunk" / "3 chunks" — the noun agrees with the number. */
+export function pluralize(count, singular, plural = `${singular}s`) {
+ return `${count} ${count === 1 ? singular : plural}`;
+}
+
+export function formatCount(value) {
+ return value.toLocaleString();
+}
+
+export function formatWhen(iso) {
+ const when = new Date(iso);
+ if (Number.isNaN(when.getTime())) return "";
+ const today = new Date();
+ const sameDay =
+ when.getFullYear() === today.getFullYear() &&
+ when.getMonth() === today.getMonth() &&
+ when.getDate() === today.getDate();
+ return sameDay
+ ? when.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
+ : when.toLocaleDateString([], { month: "short", day: "numeric" });
+}
+
+/** Maps the backend's verdict string onto a colour tone. */
+export function verdictTone(verdict) {
+ if (!verdict) return "plain";
+ if (verdict.includes("strong") || verdict === "match") return "ok";
+ if (verdict === "borderline") return "warn";
+ return "err";
+}
diff --git a/src/double_chin/studio/static/index.html b/src/double_chin/studio/static/index.html
index bc832af..bebca2d 100644
--- a/src/double_chin/studio/static/index.html
+++ b/src/double_chin/studio/static/index.html
@@ -3,144 +3,220 @@
+
Double Chin
+
-
+
-
-
-
-
-
Double Chin
-
your voice, on script
-
+
-
-
- Script
+
-
-
Voice
-
-
- + enroll
-
-
-
+
+ Script
+ Text to speak
+
-
-
-
-
Text to speak
-
-
+
-
- Delivery controls
-
-
Exaggeration 0.5
-
-
emotion intensity — 0 flat, 1 theatrical
-
-
-
Reference adherence 0.5
-
-
higher sticks closer to the reference delivery
+
+
+
+
+ Delivery
+
+
+
+
+
Expression0.50
+
+
How much emotion goes into the read. Low is flat, high is theatrical.
+
+
+
Reference adherence0.50
+
+
Higher sticks closer to the delivery in your reference recording.
+
+
+
Variation0.80
+
+
How much two takes of the same line differ from each other.
+
+
+
Speaking rate1.00×
+
+
Pitch-preserving stretch. 1× leaves the pacing untouched.
+
+
+
Seed
+
+
Set a number to get the same take back every time.
+
+
-
-
Temperature 0.8
-
-
variation between takes
-
-
-
Speaking rate 1.00 ×
-
-
pitch-preserving time-stretch — 1× unchanged, >1 faster
-
-
-
Seed
-
-
set a number to make the take reproducible
+
+
+
+
+
+ Prosody marks
+
+
+
+ [pause:0.8] Stitch a silence of that many seconds.
+ [break] A standard beat, same as a short pause.
+ *word* Lean on a word.
+ [emph]…[/emph] Lean on a whole phrase.
+
+
A blank line becomes a paragraph pause. Anything unrecognized is
+ ignored rather than spoken.
+
- Generate
-
-
+
-
- Output
+
+ Output
-
-
Pick a voice, paste a script, hit Generate .
-
First generation loads the model (~10 s on Apple GPU); synthesis runs
- at roughly one fifth of realtime, chunk by chunk — you'll see each one land below.
-
+
+ The first take loads the model, which takes about ten seconds. After that it
+ synthesizes at roughly a fifth of realtime, a chunk at a time.
+
-
-
-
queued
-
+
-
-
-
-
+
+
+
+
-
-
+
-
History
-
+
+
-
- local only · nothing leaves this machine (weights download once from Hugging Face)
-
-
+
+
+
+
+
+ Voice
+
+
+ Use this voice
+
+
+
+
+
+
+ Add or replace a voice
+
+
+
+
+
+
+
+
-
+