Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
41 changes: 38 additions & 3 deletions src/double_chin/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,51 @@
from __future__ import annotations

import socket
import subprocess
import sys
import threading
import time

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):
Expand Down Expand Up @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions src/double_chin/studio/static/api.js
Original file line number Diff line number Diff line change
@@ -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" : ""}`,
};
Loading