ACloudCenter Claude Fable 5 commited on
Commit
c8ad0ed
·
1 Parent(s): 9682ff0

Clone dialog: multi-select Use as, longer read scripts, live recording pulse

Browse files

Use 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>

Files changed (3) hide show
  1. static/app.js +49 -14
  2. static/index.html +2 -0
  3. 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
- cloneTargetSlot: 0,
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 the flavor everywhere since — big cities, little towns, my own kitchen — and nothing has ever come close to it.",
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. Does any of it work? Who knows! But they're all 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,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
- state.cloneTargetSlot = targetSlot === null ? null : Math.min(targetSlot, state.numSpeakers - 1);
 
 
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
- el.useCloneBtn.textContent = state.cloneTargetSlot === null
945
- ? "Save voice"
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
- if (state.cloneTargetSlot !== null) {
989
- state.voiceSelections[state.cloneTargetSlot] = CUSTOM_PREFIX + voice.id;
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.7; } }
937
- #recordBtn.recording { background: var(--error); border-color: var(--error); animation: recPulse 1.2s ease-in-out infinite; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;