Commit ·
c8ad0ed
1
Parent(s): 9682ff0
Clone dialog: multi-select Use as, longer read scripts, live recording pulse
Browse filesUse as returns as a multi-select — none up to all four speakers — with
the save button narrating the choice (Save voice / Save & use as
Speakers 2 & 3). It defaults to the slot the dialog was opened for, or
Speaker 1 from the sidebar so a fresh clone always has a visible home.
Read-along passages roughly doubled to ~130-145 words (~50-60s at a
relaxed pace). Recording state is unmistakable now: the Stop button
pulses a red glow ring and the timer goes red with a blinking dot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- static/app.js +49 -14
- static/index.html +2 -0
- static/styles.css +26 -2
static/app.js
CHANGED
|
@@ -48,7 +48,7 @@ const state = {
|
|
| 48 |
librarySearch: "",
|
| 49 |
libraryFilter: "all",
|
| 50 |
libraryTargetSlot: null, // non-null: library picks a voice for this one slot
|
| 51 |
-
|
| 52 |
cloneBlob: null,
|
| 53 |
cloneName: "",
|
| 54 |
cloneDuration: 0,
|
|
@@ -80,7 +80,7 @@ const el = {};
|
|
| 80 |
"logToggleBtn", "logBox",
|
| 81 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
| 82 |
"cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
|
| 83 |
-
"clonePreview", "cloneAudio", "cloneMeta", "cloneConsentCheckbox",
|
| 84 |
"cloneNameInput", "cloneReplaceRow", "cloneReplacePills", "optimizeCheckbox",
|
| 85 |
"readScriptToggle", "readScriptCard", "readScriptText", "readScriptMeta", "readScriptShuffle",
|
| 86 |
"cancelCloneBtn", "useCloneBtn",
|
|
@@ -677,11 +677,11 @@ el.librarySearch.addEventListener("input", () => {
|
|
| 677 |
// question and an exclamation in each (pitch range), varied sounds, and
|
| 678 |
// ~25-30s when read at a comfortable pace.
|
| 679 |
const READ_SCRIPTS = [
|
| 680 |
-
"Okay, so here's the thing about mornings: I always swear I'll get up early, and somehow the snooze button wins every single time. Last Tuesday I actually did it — coffee, a quick walk, the whole routine — and honestly? Best day I'd had in months. The air was cool, the streets were quiet, and for once nobody needed a single thing from me. Maybe tomorrow I'll try it again.",
|
| 681 |
-
"When I was about nine, my grandfather taught me to fish off the old wooden dock behind his house. He'd say, \"Patience isn't waiting — it's what you do while you wait.\" I had no idea what that meant back then. Now, every time I'm stuck in line or watching the kettle boil, I hear his voice again, and I catch myself smiling without meaning to.",
|
| 682 |
-
"You want to know the best meal I've ever had? A tiny noodle shop, eleven o'clock at night, rain hammering against the windows. Six seats, no menu, and a cook who never said a word. That broth changed my life! I've chased
|
| 683 |
-
"There's a particular hour just before sunset when everything slows down. The light turns gold, shadows stretch long across the yard, and even the birds seem to lower their voices. I like to sit outside then, with a cup of tea going cold beside me, and let my thoughts wander wherever they want. It never lasts long. Isn't that exactly why it matters?",
|
| 684 |
-
"Here's my confession: I talk to my plants. Not just a quick hello, either — full conversations. The fern gets encouragement, the cactus gets tough love, and the orchid? The orchid gets bribed.
|
| 685 |
];
|
| 686 |
|
| 687 |
const CLONE_MIN_SECONDS = 5; // hard floor
|
|
@@ -779,6 +779,38 @@ function cloneAtCapacity() {
|
|
| 779 |
return state.customVoices.length >= MAX_CUSTOM_VOICES;
|
| 780 |
}
|
| 781 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
function updateCloneConfirm() {
|
| 783 |
el.useCloneBtn.disabled = !(
|
| 784 |
state.cloneBlob &&
|
|
@@ -815,6 +847,7 @@ function resetRecordButton() {
|
|
| 815 |
clearInterval(recordTicker);
|
| 816 |
el.recordBtn.textContent = "● Record";
|
| 817 |
el.recordBtn.classList.remove("recording");
|
|
|
|
| 818 |
el.recordTimer.hidden = true;
|
| 819 |
}
|
| 820 |
|
|
@@ -854,6 +887,7 @@ async function startRecording() {
|
|
| 854 |
el.recordBtn.textContent = "■ Stop";
|
| 855 |
el.recordBtn.classList.add("recording");
|
| 856 |
el.recordTimer.hidden = false;
|
|
|
|
| 857 |
el.recordTimer.textContent = "0:00";
|
| 858 |
clearInterval(recordTicker);
|
| 859 |
recordTicker = setInterval(() => {
|
|
@@ -930,7 +964,9 @@ el.optimizeCheckbox.addEventListener("change", async () => {
|
|
| 930 |
});
|
| 931 |
|
| 932 |
function openCloneDialog(targetSlot = null) {
|
| 933 |
-
|
|
|
|
|
|
|
| 934 |
state.cloneBlob = null;
|
| 935 |
state.cloneName = "";
|
| 936 |
state.cloneDuration = 0;
|
|
@@ -941,9 +977,8 @@ function openCloneDialog(targetSlot = null) {
|
|
| 941 |
el.cloneMeta.textContent = "";
|
| 942 |
el.cloneNameInput.value = "";
|
| 943 |
el.cloneConsentCheckbox.checked = el.voiceConsentCheckbox.checked;
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
: `Save & use as Speaker ${state.cloneTargetSlot + 1}`;
|
| 947 |
renderCloneReplacePills();
|
| 948 |
updateCloneConfirm();
|
| 949 |
el.cloneDialog.showModal();
|
|
@@ -985,9 +1020,9 @@ el.useCloneBtn.addEventListener("click", () => {
|
|
| 985 |
state.customVoices.push(voice);
|
| 986 |
}
|
| 987 |
persistCustomVoice(voice);
|
| 988 |
-
|
| 989 |
-
state.voiceSelections[
|
| 990 |
-
}
|
| 991 |
el.voiceConsentCheckbox.checked = el.cloneConsentCheckbox.checked;
|
| 992 |
closeCloneDialog();
|
| 993 |
renderCast();
|
|
|
|
| 48 |
librarySearch: "",
|
| 49 |
libraryFilter: "all",
|
| 50 |
libraryTargetSlot: null, // non-null: library picks a voice for this one slot
|
| 51 |
+
cloneTargetSlots: [], // speaker slots the saved clone gets assigned to (0..4 of them)
|
| 52 |
cloneBlob: null,
|
| 53 |
cloneName: "",
|
| 54 |
cloneDuration: 0,
|
|
|
|
| 80 |
"logToggleBtn", "logBox",
|
| 81 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
| 82 |
"cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
|
| 83 |
+
"clonePreview", "cloneAudio", "cloneMeta", "cloneSlotRow", "cloneConsentCheckbox",
|
| 84 |
"cloneNameInput", "cloneReplaceRow", "cloneReplacePills", "optimizeCheckbox",
|
| 85 |
"readScriptToggle", "readScriptCard", "readScriptText", "readScriptMeta", "readScriptShuffle",
|
| 86 |
"cancelCloneBtn", "useCloneBtn",
|
|
|
|
| 677 |
// question and an exclamation in each (pitch range), varied sounds, and
|
| 678 |
// ~25-30s when read at a comfortable pace.
|
| 679 |
const READ_SCRIPTS = [
|
| 680 |
+
"Okay, so here's the thing about mornings: I always swear I'll get up early, and somehow the snooze button wins every single time. Last Tuesday I actually did it — coffee, a quick walk, the whole routine — and honestly? Best day I'd had in months. The air was cool, the streets were quiet, and for once nobody needed a single thing from me. I watched the bakery on the corner pull its first trays out of the oven, and the smell alone was worth the alarm. So naturally, I told everyone I was a morning person now. That lasted exactly four days! But I keep coming back to it, because those quiet hours feel like borrowed time. Maybe tomorrow I'll try it again — no promises, though.",
|
| 681 |
+
"When I was about nine, my grandfather taught me to fish off the old wooden dock behind his house. He'd say, \"Patience isn't waiting — it's what you do while you wait.\" I had no idea what that meant back then. I just liked the sandwiches, and the way the water slapped against the posts. We'd sit for hours, mostly in silence, watching dragonflies stitch back and forth across the surface. Did we ever catch much? Almost never! One summer the biggest thing we pulled up was somebody's old boot, and he laughed so hard he nearly fell in. Now, every time I'm stuck in line or watching the kettle boil, I hear his voice again, and I catch myself smiling without meaning to. Funny how the smallest afternoons turn out to be the ones you keep.",
|
| 682 |
+
"You want to know the best meal I've ever had? A tiny noodle shop, eleven o'clock at night, rain hammering against the windows. Six seats, no menu, and a cook who never said a word. He just looked at you, nodded once, and started cooking. That broth changed my life! Rich and smoky and somehow gentle at the same time, with noodles he'd pulled by hand maybe ninety seconds earlier. I sat there dripping wet and completely happy, and I ordered a second bowl before I'd finished the first. I've chased that flavor everywhere since — big cities, little towns, my own kitchen at two in the morning — and nothing has ever come close. Some things only taste right once. The trick is knowing it while it's happening, and that night, I did.",
|
| 683 |
+
"There's a particular hour just before sunset when everything slows down. The light turns gold, shadows stretch long across the yard, and even the birds seem to lower their voices. I like to sit outside then, with a cup of tea going cold beside me, and let my thoughts wander wherever they want. Sometimes they drift to old friends, or to trips I still mean to take; sometimes they don't go anywhere at all, and that's fine too. The neighbor's dog usually wanders over, flops down on the warm stones, and sighs like he's had the longest day of anyone. Honestly, he might be right! Then the streetlights blink on, the cool air moves in, and the moment is over. It never lasts long. Isn't that exactly why it matters?",
|
| 684 |
+
"Here's my confession: I talk to my plants. Not just a quick hello, either — full conversations. The fern gets encouragement, the cactus gets tough love, and the orchid? The orchid gets bribed. I promise it better light, less draft, a bigger pot in the spring — whatever it takes, because it blooms exactly when it feels like it and not a moment sooner. My sister thinks I've completely lost the plot. She stood in my kitchen last month, listening to me thank the basil for pulling through a rough week, and just slowly shook her head. But does any of it work? Who knows! All I can tell you is that every single one of them is still alive, which is more than I can say for every plant I owned before, so I'm not changing a thing.",
|
| 685 |
];
|
| 686 |
|
| 687 |
const CLONE_MIN_SECONDS = 5; // hard floor
|
|
|
|
| 779 |
return state.customVoices.length >= MAX_CUSTOM_VOICES;
|
| 780 |
}
|
| 781 |
|
| 782 |
+
function updateCloneSaveLabel() {
|
| 783 |
+
const slots = state.cloneTargetSlots;
|
| 784 |
+
if (!slots.length) {
|
| 785 |
+
el.useCloneBtn.textContent = "Save voice";
|
| 786 |
+
} else if (slots.length === 1) {
|
| 787 |
+
el.useCloneBtn.textContent = `Save & use as Speaker ${slots[0] + 1}`;
|
| 788 |
+
} else {
|
| 789 |
+
const nums = [...slots].sort().map((i) => i + 1);
|
| 790 |
+
el.useCloneBtn.textContent =
|
| 791 |
+
`Save & use as Speakers ${nums.slice(0, -1).join(", ")} & ${nums[nums.length - 1]}`;
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
function renderCloneSlots() {
|
| 796 |
+
el.cloneSlotRow.innerHTML = "";
|
| 797 |
+
for (let i = 0; i < state.numSpeakers; i += 1) {
|
| 798 |
+
const pill = document.createElement("button");
|
| 799 |
+
pill.type = "button";
|
| 800 |
+
pill.className = "clone-slot-pill";
|
| 801 |
+
pill.textContent = `Speaker ${i + 1} · ${slotVoiceLabel(i)}`;
|
| 802 |
+
pill.classList.toggle("active", state.cloneTargetSlots.includes(i));
|
| 803 |
+
pill.addEventListener("click", () => {
|
| 804 |
+
state.cloneTargetSlots = state.cloneTargetSlots.includes(i)
|
| 805 |
+
? state.cloneTargetSlots.filter((s) => s !== i)
|
| 806 |
+
: [...state.cloneTargetSlots, i];
|
| 807 |
+
renderCloneSlots();
|
| 808 |
+
updateCloneSaveLabel();
|
| 809 |
+
});
|
| 810 |
+
el.cloneSlotRow.append(pill);
|
| 811 |
+
}
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
function updateCloneConfirm() {
|
| 815 |
el.useCloneBtn.disabled = !(
|
| 816 |
state.cloneBlob &&
|
|
|
|
| 847 |
clearInterval(recordTicker);
|
| 848 |
el.recordBtn.textContent = "● Record";
|
| 849 |
el.recordBtn.classList.remove("recording");
|
| 850 |
+
el.recordTimer.classList.remove("rec-live");
|
| 851 |
el.recordTimer.hidden = true;
|
| 852 |
}
|
| 853 |
|
|
|
|
| 887 |
el.recordBtn.textContent = "■ Stop";
|
| 888 |
el.recordBtn.classList.add("recording");
|
| 889 |
el.recordTimer.hidden = false;
|
| 890 |
+
el.recordTimer.classList.add("rec-live");
|
| 891 |
el.recordTimer.textContent = "0:00";
|
| 892 |
clearInterval(recordTicker);
|
| 893 |
recordTicker = setInterval(() => {
|
|
|
|
| 964 |
});
|
| 965 |
|
| 966 |
function openCloneDialog(targetSlot = null) {
|
| 967 |
+
// Default landing spot: the slot this dialog was opened for, else Speaker 1
|
| 968 |
+
// so a fresh clone always has a visible home. Deselect all to library-only.
|
| 969 |
+
state.cloneTargetSlots = [targetSlot === null ? 0 : Math.min(targetSlot, state.numSpeakers - 1)];
|
| 970 |
state.cloneBlob = null;
|
| 971 |
state.cloneName = "";
|
| 972 |
state.cloneDuration = 0;
|
|
|
|
| 977 |
el.cloneMeta.textContent = "";
|
| 978 |
el.cloneNameInput.value = "";
|
| 979 |
el.cloneConsentCheckbox.checked = el.voiceConsentCheckbox.checked;
|
| 980 |
+
renderCloneSlots();
|
| 981 |
+
updateCloneSaveLabel();
|
|
|
|
| 982 |
renderCloneReplacePills();
|
| 983 |
updateCloneConfirm();
|
| 984 |
el.cloneDialog.showModal();
|
|
|
|
| 1020 |
state.customVoices.push(voice);
|
| 1021 |
}
|
| 1022 |
persistCustomVoice(voice);
|
| 1023 |
+
state.cloneTargetSlots.forEach((i) => {
|
| 1024 |
+
state.voiceSelections[i] = CUSTOM_PREFIX + voice.id;
|
| 1025 |
+
});
|
| 1026 |
el.voiceConsentCheckbox.checked = el.cloneConsentCheckbox.checked;
|
| 1027 |
closeCloneDialog();
|
| 1028 |
renderCast();
|
static/index.html
CHANGED
|
@@ -239,6 +239,8 @@
|
|
| 239 |
<input type="checkbox" id="optimizeCheckbox" checked />
|
| 240 |
Optimize audio — suppress mic noise, trim silence, remove rumble, level the volume
|
| 241 |
</label>
|
|
|
|
|
|
|
| 242 |
<div id="cloneReplaceRow" hidden>
|
| 243 |
<div class="clone-section-label">Voice library full — choose one to replace</div>
|
| 244 |
<div id="cloneReplacePills" class="clone-slots"></div>
|
|
|
|
| 239 |
<input type="checkbox" id="optimizeCheckbox" checked />
|
| 240 |
Optimize audio — suppress mic noise, trim silence, remove rumble, level the volume
|
| 241 |
</label>
|
| 242 |
+
<div class="clone-section-label">Use as</div>
|
| 243 |
+
<div id="cloneSlotRow" class="clone-slots"></div>
|
| 244 |
<div id="cloneReplaceRow" hidden>
|
| 245 |
<div class="clone-section-label">Voice library full — choose one to replace</div>
|
| 246 |
<div id="cloneReplacePills" class="clone-slots"></div>
|
static/styles.css
CHANGED
|
@@ -933,8 +933,32 @@ dialog::backdrop { background: rgba(42, 32, 22, 0.45); }
|
|
| 933 |
font-weight: 700;
|
| 934 |
color: var(--accent);
|
| 935 |
}
|
| 936 |
-
@keyframes recPulse { 50% { opacity: 0.
|
| 937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
.read-script-toggle {
|
| 939 |
background: none;
|
| 940 |
border: none;
|
|
|
|
| 933 |
font-weight: 700;
|
| 934 |
color: var(--accent);
|
| 935 |
}
|
| 936 |
+
@keyframes recPulse { 50% { opacity: 0.35; } }
|
| 937 |
+
@keyframes recGlow {
|
| 938 |
+
0% { box-shadow: 0 0 0 0 rgba(178, 59, 59, 0.45); }
|
| 939 |
+
70% { box-shadow: 0 0 0 14px rgba(178, 59, 59, 0); }
|
| 940 |
+
100% { box-shadow: 0 0 0 0 rgba(178, 59, 59, 0); }
|
| 941 |
+
}
|
| 942 |
+
#recordBtn.recording {
|
| 943 |
+
background: var(--error);
|
| 944 |
+
border-color: var(--error);
|
| 945 |
+
animation: recGlow 1.3s ease-out infinite;
|
| 946 |
+
}
|
| 947 |
+
.record-timer.rec-live {
|
| 948 |
+
display: inline-flex;
|
| 949 |
+
align-items: center;
|
| 950 |
+
gap: 8px;
|
| 951 |
+
color: var(--error);
|
| 952 |
+
font-size: 1.05rem;
|
| 953 |
+
}
|
| 954 |
+
.record-timer.rec-live::before {
|
| 955 |
+
content: "";
|
| 956 |
+
width: 10px;
|
| 957 |
+
height: 10px;
|
| 958 |
+
border-radius: 50%;
|
| 959 |
+
background: var(--error);
|
| 960 |
+
animation: recPulse 1.3s ease-in-out infinite;
|
| 961 |
+
}
|
| 962 |
.read-script-toggle {
|
| 963 |
background: none;
|
| 964 |
border: none;
|