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. ![Double Chin Studio](demo/studio/double-chin-studio-demo.gif) @@ -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
-
+
+
+ +

Double Chin

+ +
+ +
+ +
-
-
-
-

Script

+
-
- -
- - -
-
-
+
+

Script

+ + - - -
- - -
- 0 chars - +
+ + + -
-

- Prosody markup: - [pause:0.8] or [break] stitch silence · - *word* or [emph]…[/emph] emphasize. - Blank lines become paragraph pauses. Unknown tags are ignored, never spoken. -

+
+
-
- Delivery controls -
- - -
emotion intensity — 0 flat, 1 theatrical
-
-
- - -
higher sticks closer to the reference delivery
+
+
+ + + Delivery + + +
+
+ + +

How much emotion goes into the read. Low is flat, high is theatrical.

+
+
+ + +

Higher sticks closer to the delivery in your reference recording.

+
+
+ + +

How much two takes of the same line differ from each other.

+
+
+ + +

Pitch-preserving stretch. 1× leaves the pacing untouched.

+
+
+ + +

Set a number to get the same take back every time.

+
+
+ +
-
- - -
variation between takes
-
-
- - -
pitch-preserving time-stretch — 1× unchanged, >1 faster
-
-
- - -
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.

+
- -
-
+
+ +

+
-
-

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. +

-
-

History

-
+
+

Recent

+
+
-
- local only · nothing leaves this machine (weights download once from Hugging Face) - -
+ +
+

Settings

+ +
+ +
+
+

Voice

+
+ +

+
+ +
+

Add or replace a voice

+
+
+ + +
+
+ + +

Two or more clips let a held-out one verify the result.

+
+ +
+

+ +
+
+
+ +
+

Environment

+
+
+
+ +
+ Local only. Nothing leaves this machine — model weights download once. + +
+
- + diff --git a/src/double_chin/studio/static/style.css b/src/double_chin/studio/static/style.css index 1fe7900..21809d3 100644 --- a/src/double_chin/studio/static/style.css +++ b/src/double_chin/studio/static/style.css @@ -1,206 +1,450 @@ -/* Double Chin — MashuAI brand system (dark, minimal, mono) */ +/* Double Chin — components. Tokens live in tokens.css. + * + * Materials appear in exactly two places, which is where Apple puts them: the + * toolbar the content scrolls under, and the settings sheet that sits over the + * page. Everything else is an opaque grouped surface with hairline separators. + */ -@font-face{ - font-family:"JetBrains Mono";font-weight:400;font-style:normal;font-display:swap; - src:url("fonts/JetBrainsMono-Regular.ttf") format("truetype"); +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { height: 100%; } + +body{ + min-height: 100%; + background: var(--bg-grouped); + color: var(--label); + font-family: var(--font-ui); + font-size: var(--text-body); + line-height: 1.46; + letter-spacing: var(--tracking-body); + -webkit-font-smoothing: antialiased; } -@font-face{ - font-family:"JetBrains Mono";font-weight:500;font-style:normal;font-display:swap; - src:url("fonts/JetBrainsMono-Medium.ttf") format("truetype"); + +.numeric{ font-family: var(--font-num); font-variant-numeric: tabular-nums; } +.dim{ color: var(--label-secondary); } + +.sr-only{ + position: absolute; width: 1px; height: 1px; overflow: hidden; + clip-path: inset(50%); white-space: nowrap; } -:root{ - --bg:#0a0a0f; --surface:#0f0f14; --elevated:#141419; --hover:#1a1a21; - --line:rgba(226,232,240,.12); --line-soft:rgba(226,232,240,.07); - --text:#e2e8f0; --muted:#94a3b8; --hint:#475569; - --ok:#22c55e; --warn:#f59e0b; --err:#ef4444; - --mono:"JetBrains Mono","Menlo","SFMono-Regular",monospace; +/* ---------- toolbar ---------- + * The one piece of real glass in the window: the page scrolls beneath it, so + * the blur has actual content to work on. + */ +.toolbar{ + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; gap: var(--gap-4); + padding: 0 var(--gap-4); height: 44px; + background: var(--material-bar); + -webkit-backdrop-filter: blur(var(--material-blur)) saturate(var(--material-saturate)); + backdrop-filter: blur(var(--material-blur)) saturate(var(--material-saturate)); + border-bottom: .5px solid var(--separator); } -*{box-sizing:border-box;margin:0;padding:0} -body{ - background:var(--bg);color:var(--text);font-family:var(--mono); - font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased; - min-height:100vh;display:flex;flex-direction:column; -} - -/* ---------- header ---------- */ -header{ - display:flex;align-items:center;justify-content:space-between; - padding:14px 24px;border-bottom:1px solid var(--line-soft); -} -.brand{display:flex;align-items:center;gap:14px} -.mark{ - width:40px;height:40px;border-radius:9px;background:#0f0e13; - display:flex;align-items:flex-end;justify-content:center;gap:3px;padding:10px 0 9px; -} -.mark span{width:4px;background:#e1e0e0;border-radius:2px;display:block} -.mark span:nth-child(1),.mark span:nth-child(5){height:32%} -.mark span:nth-child(2),.mark span:nth-child(4){height:62%} -.mark span:nth-child(3){height:100%} -.name{font-weight:500;letter-spacing:-.01em} -.sub{color:var(--hint);font-size:11px;letter-spacing:.02em} -.env{color:var(--muted);font-size:12px;text-align:right} -.env b{color:var(--text);font-weight:500} - -/* ---------- layout ---------- */ -main{ - flex:1;display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:20px; - width:100%;max-width:1160px;margin:0 auto;padding:24px;align-items:start; -} -@media (max-width:860px){main{grid-template-columns:1fr}} -.panel{ - background:var(--surface);border-radius:6px;padding:22px; - box-shadow:0 1px 3px rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.3); -} -h2{font-size:13px;font-weight:500;letter-spacing:.06em;text-transform:none; - color:var(--muted);margin-bottom:16px} -.history-h{margin-top:28px} - -/* ---------- fields ---------- */ -.field{margin-bottom:16px} -label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px;letter-spacing:.02em} -select,input[type=text],input[type=number],textarea{ - width:100%;background:var(--bg);border:1px solid var(--line);border-radius:5px; - color:var(--text);font-family:var(--mono);font-size:13px;padding:9px 11px; -} -select:focus,input:focus,textarea:focus{outline:none;border-color:rgba(226,232,240,.4)} -textarea{resize:vertical;line-height:1.55} -.voice-row{display:flex;gap:8px} -.voice-row select{flex:1} -.hint{color:var(--hint);font-size:11.5px;margin-top:5px;line-height:1.5} -.meta-row{display:flex;justify-content:space-between;align-items:center;margin-top:6px} -.prosody-help{margin-top:8px} -.prosody-help code{ - color:var(--muted);background:var(--bg);border:1px solid var(--line-soft); - border-radius:3px;padding:0 4px;font-family:var(--mono);font-size:11px; +.identity{ display: flex; align-items: center; gap: var(--gap-2); min-width: 0; } +.mark{ display: flex; color: var(--accent); } +.mark svg{ width: 18px; height: 13px; display: block; } +.voice-label{ + font-size: var(--text-body); font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-title); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* ---------- buttons ---------- */ -button{ - font-family:var(--mono);font-size:13px;font-weight:500;border-radius:5px; - cursor:pointer;transition:background .15s ease,border-color .15s ease,opacity .15s ease; -} -.ghost{ - background:transparent;border:1px solid rgba(226,232,240,.25);color:var(--text); - padding:9px 14px;white-space:nowrap; -} -.ghost:hover{background:rgba(226,232,240,.06);border-color:rgba(226,232,240,.45)} -.primary{ - width:100%;background:var(--text);color:var(--bg);border:none;padding:12px; - font-size:14px;margin-top:4px; -} -.primary:hover{background:#fff} -.primary:disabled{opacity:.45;cursor:default} -button:focus-visible,a:focus-visible,summary:focus-visible{ - outline:1px solid rgba(226,232,240,.4);outline-offset:2px; -} -.ghost-link{ - color:var(--muted);font-size:12px;cursor:pointer;text-decoration:none; - border-bottom:1px solid var(--line); -} -.ghost-link:hover{color:var(--text);border-bottom-color:var(--muted)} -.row-end{display:flex;justify-content:flex-end;margin-top:4px} -.eta{text-align:center;min-height:1.2em;margin-top:8px} - -/* ---------- enroll ---------- */ -.enroll{ - background:var(--bg);border:1px solid var(--line-soft);border-radius:6px; - padding:14px;margin-bottom:16px; -} -input[type=file]{color:var(--muted);font-size:12px} -input[type=file]::file-selector-button{ - font-family:var(--mono);background:transparent;border:1px solid rgba(226,232,240,.25); - color:var(--text);border-radius:4px;padding:5px 10px;margin-right:10px;cursor:pointer; +.toolbar-end{ display: flex; align-items: center; gap: var(--gap-2); } + +.pill{ + font-size: var(--text-small); color: var(--label-secondary); + padding: 2px 8px; border-radius: var(--r-pill); + background: var(--fill-tertiary); + white-space: nowrap; +} +.pill b{ color: var(--label); font-weight: var(--weight-medium); } + +.icon-button{ + display: grid; place-items: center; width: 26px; height: 26px; + border: 0; border-radius: 6px; + background: transparent; color: var(--label-secondary); cursor: pointer; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.icon-button svg{ width: 16px; height: 16px; } +.icon-button:hover{ background: var(--fill-tertiary); color: var(--label); } +.icon-button:active{ background: var(--fill-secondary); } + +/* ---------- shell ---------- */ +.shell{ + width: 100%; max-width: var(--shell-width); + margin: 0 auto; padding: var(--gap-5) var(--gap-5) 56px; + display: flex; flex-direction: column; gap: var(--gap-3); +} + +.section-title{ + font-size: var(--text-small); font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); text-transform: uppercase; + color: var(--label-secondary); + margin: var(--gap-4) 0 var(--gap-2) var(--gap-3); +} + +/* ---------- grouped surfaces ---------- */ +.composer, .disclosure, .take, .history-item{ + background: var(--bg-surface); + border-radius: var(--r-card); + border: .5px solid var(--separator); +} + +/* ---------- composer ---------- */ +.composer{ display: flex; flex-direction: column; } + +.composer textarea{ + display: block; width: 100%; min-height: 156px; resize: vertical; + padding: var(--gap-3) var(--gap-4); + background: transparent; border: 0; outline: none; + color: var(--label); font-family: inherit; font-size: var(--text-title3); + line-height: 1.5; letter-spacing: var(--tracking-body); +} +.composer textarea::placeholder{ color: var(--label-tertiary); } + +.composer-foot{ + display: flex; align-items: center; justify-content: space-between; gap: var(--gap-3); + padding: var(--gap-2) var(--gap-4); + border-top: .5px solid var(--separator); + font-size: var(--text-small); color: var(--label-secondary); +} + +.link{ + color: var(--accent); font-size: var(--text-small); cursor: pointer; + background: none; border: 0; padding: 0; font-family: inherit; + text-decoration: none; border-radius: 4px; +} +.link:hover{ text-decoration: underline; } + +/* ---------- disclosures ---------- */ +.disclosures{ display: flex; flex-direction: column; gap: var(--gap-2); } +.disclosure{ overflow: hidden; } + +.disclosure summary{ + display: flex; align-items: center; gap: var(--gap-2); + padding: 0 var(--gap-4); height: 40px; cursor: pointer; list-style: none; + font-size: var(--text-body); color: var(--label); +} +.disclosure summary::-webkit-details-marker{ display: none; } +.disclosure summary:hover{ background: var(--fill-quaternary); } + +/* AppKit's disclosure triangle: points right when closed, down when open. */ +.disc-chevron{ + width: 0; height: 0; flex: none; + border-left: 5px solid var(--label-tertiary); + border-top: 3.5px solid transparent; + border-bottom: 3.5px solid transparent; + transition: transform var(--dur) var(--ease); +} +.disclosure[open] .disc-chevron{ transform: rotate(90deg); } + +.disc-title{ flex: 1; } +.disc-value{ font-size: var(--text-small); color: var(--label-secondary); } + +.disc-body{ + padding: 0 var(--gap-4) var(--gap-3); + border-top: .5px solid var(--separator); } +.disc-foot{ display: flex; justify-content: flex-end; padding-top: var(--gap-2); } + /* ---------- knobs ---------- */ -.knobs{margin-bottom:18px;border:1px solid var(--line-soft);border-radius:6px;padding:0} -.knobs summary{ - cursor:pointer;color:var(--muted);font-size:12px;padding:10px 14px;list-style:none; -} -.knobs summary::before{content:"▸ ";color:var(--hint)} -.knobs[open] summary::before{content:"▾ "} -.knobs summary::-webkit-details-marker{display:none} -.knob{padding:6px 14px 12px} -.knob label{display:flex;justify-content:space-between} -.knob output{color:var(--text);font-variant-numeric:tabular-nums} +.knob{ padding: var(--gap-3) 0 var(--gap-2); } +.knob + .knob{ border-top: .5px solid var(--separator); } +.knob label{ + display: flex; align-items: baseline; justify-content: space-between; gap: var(--gap-3); + font-size: var(--text-body); color: var(--label); margin-bottom: var(--gap-2); +} +.knob output{ font-size: var(--text-callout); color: var(--label-secondary); } +.knob .hint{ font-size: var(--text-small); color: var(--label-secondary); margin-top: 6px; } + +.knob-inline{ display: grid; grid-template-columns: 1fr auto; align-items: center; gap: var(--gap-3); } +.knob-inline label{ margin-bottom: 0; } +.knob-inline .hint{ grid-column: 1 / -1; margin-top: 4px; } + +/* AppKit slider: thin track, accent fill to the knob, white knob with shadow. */ input[type=range]{ - width:100%;appearance:none;-webkit-appearance:none;height:3px;border-radius:2px; - background:var(--line);outline:none; + -webkit-appearance: none; appearance: none; + width: 100%; height: 4px; border-radius: var(--r-pill); outline: none; cursor: pointer; + background: linear-gradient( + to right, + var(--accent) 0 var(--fill, 50%), + var(--fill-secondary) var(--fill, 50%) 100%); } input[type=range]::-webkit-slider-thumb{ - appearance:none;-webkit-appearance:none;width:14px;height:14px;border-radius:50%; - background:var(--text);border:none;cursor:pointer; -} -input[type=range]::-moz-range-thumb{ - width:14px;height:14px;border-radius:50%;background:var(--text);border:none;cursor:pointer; -} -.knob .hint{margin-top:3px} -.knob.seed input{max-width:160px} - -/* ---------- job progress ---------- */ -.empty p{color:var(--muted);margin-bottom:8px} -.empty b{color:var(--text);font-weight:500} -.job-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px} -.status{font-size:13px;color:var(--warn)} -.status.done{color:var(--ok)} -.status.error{color:var(--err)} -.bar{height:4px;border-radius:2px;background:var(--line-soft);overflow:hidden;margin-bottom:14px} -#bar-fill{ - height:100%;width:0%;background:var(--text);border-radius:2px; - transition:width .3s ease; -} -.chunks{list-style:none;display:grid;gap:6px;max-height:300px;overflow-y:auto} + -webkit-appearance: none; appearance: none; + width: 16px; height: 16px; border-radius: 50%; + background: #fff; border: 0; box-shadow: var(--shadow-knob); +} + +/* ---------- text inputs ---------- */ +input[type=text], input[type=number], input[type=file], select{ + width: 100%; padding: 5px 8px; + background: var(--bg-inset); + border: .5px solid var(--separator); + border-radius: 6px; + color: var(--label); font-family: inherit; font-size: var(--text-body); + outline: none; + transition: border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +input[type=number]{ font-family: var(--font-num); } +.knob-inline input[type=number]{ width: 116px; } +input::placeholder{ color: var(--label-tertiary); } +select{ cursor: pointer; } + +/* AppKit's focus ring: a three-point accent halo, not a hard outline. */ +input:focus, select:focus{ + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} + +input[type=file]{ padding: 4px; font-size: var(--text-callout); color: var(--label-secondary); } +input[type=file]::file-selector-button{ + font-family: inherit; font-size: var(--text-callout); font-weight: var(--weight-medium); + background: var(--bg-surface); color: var(--label); + border: .5px solid var(--separator); border-radius: 5px; + padding: 4px 10px; margin-right: var(--gap-2); cursor: pointer; +} +input[type=file]::file-selector-button:hover{ background: var(--fill-quaternary); } + +.checkbox{ + display: flex; align-items: center; gap: var(--gap-2); + font-size: var(--text-callout); color: var(--label-secondary); cursor: pointer; + margin-top: var(--gap-3); +} +.checkbox input{ accent-color: var(--accent); width: 14px; height: 14px; } + +/* ---------- buttons ---------- */ +.button-primary{ + display: flex; align-items: center; justify-content: center; gap: var(--gap-2); + width: 100%; height: 36px; padding: 0 var(--gap-4); + background: var(--accent); color: var(--accent-label); + border: 0; border-radius: var(--r-control); + font-family: inherit; font-size: var(--text-body); font-weight: var(--weight-medium); + letter-spacing: var(--tracking-body); cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} +.button-primary:hover{ background: var(--accent-pressed); } +.button-primary:active{ background: var(--accent-pressed); } +.button-primary:disabled{ + background: var(--fill-secondary); color: var(--label-tertiary); cursor: default; +} + +.button-secondary{ + height: 24px; padding: 0 var(--gap-3); + background: var(--bg-surface); color: var(--label); + border: .5px solid var(--separator); border-radius: 6px; + font-family: inherit; font-size: var(--text-callout); cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} +.button-secondary:hover{ background: var(--fill-quaternary); } +.button-secondary:disabled{ color: var(--label-tertiary); cursor: default; } + +kbd{ + font-family: inherit; font-size: var(--text-callout); + color: currentColor; opacity: .68; +} + +.eta{ + min-height: 1.3em; margin-top: var(--gap-2); text-align: center; + font-size: var(--text-small); color: var(--label-secondary); +} + +/* ---------- stage ---------- */ +.stage-idle{ + font-size: var(--text-callout); color: var(--label-secondary); + text-align: center; max-width: 44ch; margin: var(--gap-3) auto; text-wrap: balance; +} + +.take{ padding: var(--gap-3) var(--gap-4) var(--gap-4); } + +.take-head{ + display: flex; align-items: center; justify-content: space-between; + gap: var(--gap-3); flex-wrap: wrap; margin-bottom: var(--gap-3); +} +.status{ font-size: var(--text-body); color: var(--label); } +.status.done{ color: var(--green); } +.status.error{ color: var(--red); } +.meta{ font-size: var(--text-small); color: var(--label-secondary); } + +.track{ + height: 4px; border-radius: var(--r-pill); overflow: hidden; + background: var(--fill-secondary); margin-bottom: var(--gap-3); +} +.track-fill{ + height: 100%; width: 0; border-radius: var(--r-pill); + background: var(--accent); + transition: width var(--dur) var(--ease-out); +} + +.chunks{ list-style: none; display: grid; gap: 1px; max-height: 200px; overflow-y: auto; } .chunks li{ - font-size:12px;color:var(--hint);padding:7px 10px;border-radius:4px; - background:var(--bg);border:1px solid var(--line-soft); - white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + font-size: var(--text-callout); color: var(--label-tertiary); + padding: 6px var(--gap-2); border-radius: 5px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: color var(--dur) var(--ease); +} +.chunks li .idx{ + font-family: var(--font-num); font-size: var(--text-small); + color: var(--label-tertiary); margin-right: var(--gap-2); } -.chunks li.active{color:var(--text);border-color:rgba(226,232,240,.3)} -.chunks li.done-chunk{color:var(--muted)} -.chunks li .idx{color:var(--hint);margin-right:8px} +.chunks li.active{ color: var(--label); background: var(--fill-quaternary); } +.chunks li.done-chunk{ color: var(--label-secondary); } + +.verdict{ + font-size: var(--text-small); font-weight: var(--weight-medium); + padding: 2px 8px; border-radius: var(--r-pill); + background: var(--fill-tertiary); color: var(--label-secondary); +} +.verdict.ok{ color: var(--green); } +.verdict.warn{ color: var(--orange); } +.verdict.err{ color: var(--red); } + +audio{ width: 100%; height: 32px; display: block; } + +.take-foot{ display: flex; justify-content: flex-end; margin-top: var(--gap-3); } + .error{ - margin-top:12px;padding:10px 12px;border-radius:5px;font-size:12.5px; - color:var(--err);border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.06); -} - -/* ---------- result ---------- */ -.result{margin-top:18px;padding-top:18px;border-top:1px solid var(--line-soft)} -.result-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} -.chip{ - font-size:11px;letter-spacing:.06em;padding:3px 9px;border-radius:4px;border:1px solid; -} -.chip.ok{color:var(--ok);border-color:rgba(34,197,94,.4)} -.chip.warn{color:var(--warn);border-color:rgba(245,158,11,.4)} -.chip.err{color:var(--err);border-color:rgba(239,68,68,.4)} -.chip.plain{color:var(--muted);border-color:var(--line)} -audio{width:100%;height:40px} - -/* ---------- history ---------- */ -.history{display:grid;gap:8px} -.history .item{ - display:grid;grid-template-columns:1fr auto;gap:2px 12px;align-items:center; - background:var(--bg);border:1px solid var(--line-soft);border-radius:5px; - padding:10px 12px;cursor:pointer;transition:background .15s ease,border-color .15s ease; -} -.history .item:hover{background:var(--hover);border-color:var(--line)} -.history .item .txt{ - font-size:12.5px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; -} -.history .item .sub{grid-column:1;font-size:11px;color:var(--hint)} -.history .item .score{font-size:12px;font-variant-numeric:tabular-nums;text-align:right} -.history .item .score.ok{color:var(--ok)} -.history .item .score.warn{color:var(--warn)} -.history .empty-hint{color:var(--hint);font-size:12px} - -/* ---------- footer ---------- */ -footer{ - display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap; - padding:14px 24px;border-top:1px solid var(--line-soft); - color:var(--hint);font-size:11.5px; -} - -@media (prefers-reduced-motion:reduce){ - *,#bar-fill{transition:none !important} + margin-top: var(--gap-3); padding: var(--gap-2) var(--gap-3); border-radius: 6px; + font-size: var(--text-callout); color: var(--red); + background: var(--fill-quaternary); +} + +/* ---------- history ---------- + * An inset grouped list: one rounded container, rows divided by hairlines. + */ +.history{ + background: var(--bg-surface); + border: .5px solid var(--separator); + border-radius: var(--r-card); + overflow: hidden; +} +.history-item{ + display: grid; grid-template-columns: 1fr auto; gap: 1px var(--gap-3); + align-items: center; padding: var(--gap-2) var(--gap-4); + border: 0; border-radius: 0; background: transparent; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} +.history-item + .history-item{ border-top: .5px solid var(--separator); } +.history-item:hover{ background: var(--fill-quaternary); } +.history-item[aria-disabled="true"]{ cursor: default; } +.history-item .txt{ + grid-column: 1; + font-size: var(--text-body); color: var(--label); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.history-item .sub{ grid-column: 1; font-size: var(--text-small); color: var(--label-secondary); } +.history-item .score{ + grid-column: 2; grid-row: 1 / span 2; + font-family: var(--font-num); font-variant-numeric: tabular-nums; + font-size: var(--text-callout); color: var(--label-secondary); text-align: right; +} +.history-item .score.ok{ color: var(--green); } +.history-item .score.warn{ color: var(--orange); } +.history-item .score.err{ color: var(--red); } +.history-empty{ + font-size: var(--text-callout); color: var(--label-secondary); + padding: var(--gap-3) var(--gap-4); +} + +/* ---------- settings sheet ---------- + * The second legitimate material: it sits over the page, so it has the app's + * own content to blur. + */ +.sheet{ + margin: auto; + width: min(480px, calc(100vw - 40px)); + max-height: min(76vh, 660px); + padding: 0; color: var(--label); + border: .5px solid var(--separator); + border-radius: var(--r-sheet); + background: var(--material-sheet); + -webkit-backdrop-filter: blur(var(--material-sheet-blur)) saturate(var(--material-saturate)); + backdrop-filter: blur(var(--material-sheet-blur)) saturate(var(--material-saturate)); + box-shadow: var(--shadow-sheet); + overflow: hidden; +} +.sheet[open]{ display: flex; flex-direction: column; } +.sheet::backdrop{ background: var(--scrim); } + +.sheet-head{ + display: flex; align-items: center; justify-content: space-between; + padding: var(--gap-2) var(--gap-3) var(--gap-2) var(--gap-4); + border-bottom: .5px solid var(--separator); +} +.sheet-head h2{ + font-size: var(--text-title3); font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-title); +} + +.sheet-body{ padding: 0 var(--gap-4) var(--gap-4); overflow-y: auto; } +.sheet-section{ padding: var(--gap-4) 0; } +.sheet-section + .sheet-section{ border-top: .5px solid var(--separator); } +.sheet-section h3{ + font-size: var(--text-small); font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); text-transform: uppercase; + color: var(--label-secondary); margin-bottom: var(--gap-2); +} + +.voice-card{ + display: flex; align-items: baseline; justify-content: space-between; gap: var(--gap-3); + padding: var(--gap-2) var(--gap-3); border-radius: 6px; + background: var(--fill-quaternary); +} +.voice-card .voice-name{ font-size: var(--text-body); font-weight: var(--weight-semibold); } +.voice-card .voice-meta{ font-size: var(--text-small); color: var(--label-secondary); } + +.field{ margin-bottom: var(--gap-3); } +.field label{ + display: block; font-size: var(--text-callout); color: var(--label-secondary); + margin-bottom: var(--gap-1); +} +.field-foot{ + display: flex; align-items: center; justify-content: space-between; + gap: var(--gap-3); margin-top: var(--gap-4); +} +.hint{ font-size: var(--text-small); color: var(--label-secondary); } +.voice-switch{ margin-top: var(--gap-3); margin-bottom: var(--gap-2); } + +.marks{ display: grid; grid-template-columns: auto 1fr; gap: 6px var(--gap-3); align-items: baseline; } +.marks dt{ font-size: var(--text-small); color: var(--label); white-space: nowrap; } +.marks dd{ font-size: var(--text-callout); color: var(--label-secondary); } + +.facts{ display: grid; grid-template-columns: auto 1fr; gap: 6px var(--gap-4); } +.facts dt{ font-size: var(--text-callout); color: var(--label-secondary); } +.facts dd{ + font-family: var(--font-num); font-size: var(--text-callout); color: var(--label); + overflow-wrap: anywhere; +} + +.sheet-foot{ + display: flex; align-items: center; justify-content: space-between; gap: var(--gap-3); + padding: var(--gap-2) var(--gap-4); border-top: .5px solid var(--separator); + font-size: var(--text-small); color: var(--label-secondary); +} + +/* ---------- focus ---------- */ +:focus-visible{ + outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 1px; + border-radius: 5px; +} + +/* ---------- responsive ---------- */ +@media (max-width: 600px){ + .shell{ padding: var(--gap-4) var(--gap-3) 44px; } + .pill{ display: none; } + .composer textarea{ min-height: 128px; font-size: var(--text-body); } +} + +/* ---------- reduced motion ---------- */ +@media (prefers-reduced-motion: reduce){ + *, *::before, *::after{ + animation-duration: 1ms !important; + transition-duration: 1ms !important; + } } diff --git a/src/double_chin/studio/static/tokens.css b/src/double_chin/studio/static/tokens.css new file mode 100644 index 0000000..eb3568e --- /dev/null +++ b/src/double_chin/studio/static/tokens.css @@ -0,0 +1,176 @@ +/* Double Chin — Apple design system. + * + * Values are AppKit/UIKit semantic colours, not invented ones. Two deliberate + * departures, both noted inline: the secondary label alpha is raised because + * Apple's own value fails WCAG AA for small text, and the accent has a defined + * pressed shade rather than a brightness filter. + * + * There is no decorative background anywhere in this file. Apple's materials + * blur the app's own content as it scrolls beneath them; a synthetic gradient + * behind the glass is the thing that makes translucency look fake. + */ + +:root{ + color-scheme: light dark; + + /* ---------- type ---------- + * SF via -apple-system. macOS body text is 13px, secondary 11px. + */ + --font-ui: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", + system-ui, sans-serif; + --font-num: + ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; + + --text-caption: 10px; + --text-small: 11px; + --text-callout: 12px; + --text-body: 13px; + --text-title3: 15px; + --text-title2: 17px; + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + + /* SF tightens optically as it grows; these match Apple's tracking table + * closely enough at the two sizes that carry headings. */ + --tracking-body: -0.006em; + --tracking-title: -0.014em; + --tracking-caps: 0.055em; + + /* ---------- backgrounds ---------- + * Apple's grouped-content system: a recessed page, opaque cards on top. + */ + --bg-grouped: #F2F2F7; + --bg-surface: #FFFFFF; + --bg-inset: #FFFFFF; + + /* Fills, for tracks and unemphasized control backgrounds. */ + --fill-secondary: rgba(120,120,128,.16); + --fill-tertiary: rgba(120,120,128,.12); + --fill-quaternary:rgba(120,120,128,.08); + + /* ---------- labels ---------- */ + --label: rgba(0,0,0,.92); + /* Apple's secondaryLabel is rgba(60,60,67,.60), which measures 3.5:1 on + * white and fails AA for text this size. Raised until it clears 4.5:1. */ + --label-secondary: rgba(60,60,67,.74); + --label-tertiary: rgba(60,60,67,.42); + + --separator: rgba(60,60,67,.29); + --separator-opaque:#D8D8DC; + + /* ---------- accent and semantics ---------- */ + --accent: #007AFF; + --accent-pressed: #0062CC; + --accent-label: #FFFFFF; + + /* systemGreen/Orange/Red, darkened where they sit on white as text. */ + --green: #248A3D; + --orange:#B25000; + --red: #D70015; + + /* ---------- materials ---------- + * Only chrome uses these: the toolbar, which the page scrolls beneath, and + * the sheet, which sits over the page. Nothing else in the app is glass, + * because nothing else has anything behind it to show through. + */ + --material-bar: rgba(246,246,248,.72); + --material-sheet: rgba(246,246,248,.86); + --material-blur: 20px; + --material-sheet-blur: 30px; + --material-saturate: 180%; + --scrim: rgba(0,0,0,.22); + + /* ---------- geometry ---------- */ + --r-control: 8px; + --r-card: 12px; + --r-sheet: 12px; + --r-pill: 999px; + + --gap-1: 4px; + --gap-2: 8px; + --gap-3: 12px; + --gap-4: 16px; + --gap-5: 20px; + --gap-6: 28px; + + --shell-width: 680px; + + /* Cards in a grouped layout carry no shadow — only things that genuinely + * float above the window do. */ + --shadow-sheet: 0 12px 40px rgba(0,0,0,.22), 0 2px 6px rgba(0,0,0,.10); + --shadow-knob: 0 1px 3px rgba(0,0,0,.24), 0 0 0 .5px rgba(0,0,0,.08); + + /* ---------- motion ---------- + * Apple's default UI curve, and its short durations. Nothing drifts, + * pulses, or animates on its own. + */ + --dur-fast: 120ms; + --dur: 200ms; + --ease: cubic-bezier(.25, .1, .25, 1); + --ease-out: cubic-bezier(0, 0, .58, 1); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg-grouped: #1C1C1E; + --bg-surface: #2C2C2E; + --bg-inset: #1C1C1E; + + --fill-secondary: rgba(120,120,128,.36); + --fill-tertiary: rgba(120,120,128,.24); + --fill-quaternary:rgba(120,120,128,.18); + + --label: rgba(255,255,255,.96); + --label-secondary: rgba(235,235,245,.64); + --label-tertiary: rgba(235,235,245,.34); + + --separator: rgba(84,84,88,.72); + --separator-opaque: #38383A; + + --accent: #0A84FF; + --accent-pressed: #0060D0; + + --green: #30D158; + --orange:#FF9F0A; + --red: #FF453A; + + --material-bar: rgba(28,28,30,.72); + --material-sheet: rgba(38,38,40,.86); + --scrim: rgba(0,0,0,.44); + + --shadow-sheet: 0 12px 40px rgba(0,0,0,.56), 0 2px 6px rgba(0,0,0,.36); + --shadow-knob: 0 1px 3px rgba(0,0,0,.44), 0 0 0 .5px rgba(0,0,0,.30); + } +} + +/* Increased contrast: Apple drops translucency and firms up separators. */ +@media (prefers-contrast: more) { + :root{ + --material-bar: #F2F2F7; + --material-sheet: #FFFFFF; + --material-blur: 0px; + --material-sheet-blur: 0px; + --label: rgba(0,0,0,1); + --label-secondary: rgba(0,0,0,.84); + --label-tertiary: rgba(0,0,0,.62); + --separator: rgba(0,0,0,.52); + --fill-secondary: rgba(120,120,128,.34); + --accent: #0040DD; + } +} + +@media (prefers-contrast: more) and (prefers-color-scheme: dark) { + :root{ + --material-bar: #1C1C1E; + --material-sheet: #2C2C2E; + --label: rgba(255,255,255,1); + --label-secondary: rgba(255,255,255,.88); + --label-tertiary: rgba(255,255,255,.66); + --separator: rgba(255,255,255,.56); + --accent: #4CA6FF; + --accent-label: #001022; + } +} diff --git a/tests/test_desktop.py b/tests/test_desktop.py index 0708930..45fe4f4 100644 --- a/tests/test_desktop.py +++ b/tests/test_desktop.py @@ -13,7 +13,17 @@ from __future__ import annotations -from double_chin.desktop import _acquire_single_instance_lock, _is_multiprocessing_child +import re +from pathlib import Path + +import double_chin +from double_chin.desktop import ( + _LAUNCH_COLOR_DARK, + _LAUNCH_COLOR_LIGHT, + _acquire_single_instance_lock, + _is_multiprocessing_child, + _launch_background_color, +) def test_genuine_launch_argv_is_not_a_multiprocessing_child(): @@ -65,3 +75,29 @@ def test_single_instance_lock_releases_after_close(tmp_path, monkeypatch): assert second is not None if hasattr(second, "close"): second.close() + + +class TestLaunchBackgroundColor: + """The window's pre-paint fill must track the interface's own canvas. + + These are two files that have to agree: `desktop.py` picks the colour the + native window shows before the page loads, and `tokens.css` defines the + colour the page then paints. When they drift, every launch flashes. + """ + + def test_dark_appearance_uses_the_dark_launch_colour(self): + assert _launch_background_color(prefers_dark=lambda: True) == _LAUNCH_COLOR_DARK + + def test_light_appearance_uses_the_light_launch_colour(self): + assert _launch_background_color(prefers_dark=lambda: False) == _LAUNCH_COLOR_LIGHT + + def test_launch_colours_match_the_canvas_tokens_in_css(self): + tokens = ( + Path(double_chin.__file__).parent / "studio" / "static" / "tokens.css" + ).read_text() + canvases = re.findall(r"--bg-grouped:\s*(#[0-9A-Fa-f]{6});", tokens) + + assert canvases == [_LAUNCH_COLOR_LIGHT, _LAUNCH_COLOR_DARK], ( + "tokens.css --bg-grouped values drifted from desktop.py's launch colours; " + f"css has {canvases}" + )