apolinario commited on
Commit
e0cb8bc
·
1 Parent(s): 77deeb4

Disentangle gen wall-clock from audio length; convert recorded blobs to WAV before upload

Browse files
Files changed (1) hide show
  1. index.html +102 -38
index.html CHANGED
@@ -1745,6 +1745,40 @@ function pickRecorderMimeType() {
1745
  return "";
1746
  }
1747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1748
  window.toggleRecord = async function() {
1749
  const btn = document.getElementById("record-mic-btn");
1750
  const hint = document.getElementById("record-hint");
@@ -1767,22 +1801,25 @@ window.toggleRecord = async function() {
1767
  mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream);
1768
  recordedChunks = [];
1769
  mediaRecorder.ondataavailable = e => { if (e.data && e.data.size) recordedChunks.push(e.data); };
1770
- mediaRecorder.onstop = () => {
1771
  clearInterval(recordTimerInterval);
1772
  btn.classList.remove("recording");
1773
  const elapsedSec = (Date.now() - recordStartTime) / 1000;
1774
  const type = mediaRecorder.mimeType || "audio/webm";
1775
- const blob = new Blob(recordedChunks, { type });
1776
- const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm";
1777
- const fileName = `recording_${Date.now()}.${ext}`;
1778
- blob.arrayBuffer().then(buf => {
1779
- modalFileData = { name: fileName, data: buf, type };
 
1780
  const preview = document.getElementById("record-preview");
1781
- preview.src = URL.createObjectURL(blob);
1782
  preview.style.display = "";
1783
  hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
1784
  checkModalSave();
1785
- });
 
 
1786
  if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
1787
  };
1788
  recordStartTime = Date.now();
@@ -1947,6 +1984,7 @@ window.generateDesign = async function() {
1947
  progressFill.style.width = "0%";
1948
 
1949
  const sessionHash = crypto.randomUUID();
 
1950
 
1951
  try {
1952
  const joinRes = await fetch(`${QWEN_BASE}/queue/join`, {
@@ -1989,14 +2027,14 @@ window.generateDesign = async function() {
1989
  else if (data.msg === "process_completed") {
1990
  progressFill.style.width = "100%";
1991
  if (data.success) {
1992
- const dur = data.output.duration || 0;
1993
  designAudioUrl = data.output.data[0].url;
1994
  document.getElementById("design-audio-player").src = designAudioUrl;
1995
  document.getElementById("design-player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
1996
- document.getElementById("design-player-meta").textContent = `Voice Design · ${dur.toFixed(1)}s · ${text.length} chars`;
1997
  result.classList.add("visible");
1998
- showDesignStatus("success", `Generated in ${dur.toFixed(1)}s`);
1999
- addToHistory(text, "Voice Design", dur, designAudioUrl);
2000
  } else {
2001
  const errMsg = data.output?.error || "Generation failed";
2002
  showDesignStatusWithCTA(errMsg);
@@ -2118,6 +2156,7 @@ window.transcribe = async function() {
2118
  const lang = document.getElementById("transcribe-lang").value;
2119
  const btn = transcribeBtn;
2120
  const result = document.getElementById("transcribe-result");
 
2121
 
2122
  btn.disabled = true;
2123
  btn.classList.add("loading");
@@ -2182,7 +2221,7 @@ window.transcribe = async function() {
2182
  const transcript = data.output.data[0];
2183
  document.getElementById("transcribe-text").textContent = transcript;
2184
  result.style.display = "block";
2185
- showTranscribeStatus("success", `Transcribed in ${data.output.duration?.toFixed(1)}s`);
2186
  } else {
2187
  showTranscribeStatusWithCTA(data.output?.error || "Transcription failed");
2188
  }
@@ -2897,21 +2936,26 @@ window.toggleInputRecord = async function(prefix) {
2897
  document.getElementById(`${prefix}-timer`).textContent = `${Math.floor(e/60)}:${(e%60).toString().padStart(2,"0")}`;
2898
  }, 200);
2899
  inputRecorders[prefix] = { mediaRecorder: mr, stream, chunks, timerInterval, startTime };
2900
- mr.onstop = () => {
2901
  clearInterval(timerInterval);
2902
  btn.classList.remove("recording");
2903
  const elapsedSec = (Date.now() - startTime) / 1000;
2904
  const type = mr.mimeType || "audio/webm";
2905
- const blob = new Blob(chunks, { type });
2906
- const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm";
2907
- const file = new File([blob], `recording_${Date.now()}.${ext}`, { type });
2908
- const preview = document.getElementById(`${prefix}-preview-audio`);
2909
- preview.src = URL.createObjectURL(blob);
2910
- preview.style.display = "";
2911
- hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
2912
- stream.getTracks().forEach(t => t.stop());
2913
- delete inputRecorders[prefix];
2914
- inputRecordHandlers[prefix]?.(file);
 
 
 
 
 
2915
  };
2916
  hint.textContent = "Recording… click again to stop";
2917
  btn.classList.add("recording");
@@ -3007,6 +3051,7 @@ async function uploadFile(base, file) {
3007
  // Run a queue/join + queue/data SSE call. Returns { output, duration }.
3008
  // statusElId/progressFillId are optional and used for UI updates.
3009
  async function gradioCall({ base, fnIndex, data, statusElId, progressFillId, processingMsg }) {
 
3010
  const session = crypto.randomUUID();
3011
  if (statusElId) showStatusOn(statusElId, "info", "Joining queue...");
3012
 
@@ -3054,7 +3099,10 @@ async function gradioCall({ base, fnIndex, data, statusElId, progressFillId, pro
3054
  }
3055
  if (evt.msg === "process_completed") {
3056
  if (progressFillId) document.getElementById(progressFillId).style.width = "100%";
3057
- if (evt.success) return { output: evt.output, duration: evt.output.duration || 0 };
 
 
 
3058
  throw new Error(evt.output?.error || "Generation failed");
3059
  }
3060
  }
@@ -3062,6 +3110,21 @@ async function gradioCall({ base, fnIndex, data, statusElId, progressFillId, pro
3062
  throw new Error("Stream ended without completion");
3063
  }
3064
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3065
  // Pull a URL out of a gradio Audio output (object {url} or {path} ref).
3066
  function audioUrlFromOutput(out, base) {
3067
  if (!out) return null;
@@ -3104,7 +3167,7 @@ window.voiceChange = async function() {
3104
  showStatusOn("vc-status", "info", "Uploading audio...");
3105
  const [src, tgt] = await Promise.all([uploadFile(SEED_VC_BASE, vcSource), uploadFile(SEED_VC_BASE, vcTarget)]);
3106
 
3107
- const { output, duration } = await gradioCall({
3108
  base: SEED_VC_BASE,
3109
  fnIndex: 3,
3110
  data: [src, tgt, parseInt(vcSteps.value), 1.0, 0.0, vcSim.value / 100, 0.9, 1.0, 1.0, false, false],
@@ -3118,9 +3181,9 @@ window.voiceChange = async function() {
3118
  if (!url) throw new Error("No audio returned");
3119
  resultUrls.vc = url;
3120
  document.getElementById("vc-audio-player").src = url;
3121
- document.getElementById("vc-player-meta").textContent = `${vcSource.name} → ${vcTarget.name} · ${duration.toFixed(1)}s`;
3122
  result.classList.add("visible");
3123
- showStatusOn("vc-status", "success", `Converted in ${duration.toFixed(1)}s`);
3124
  } catch (err) {
3125
  showStatusCtaOn("vc-status", err.message);
3126
  } finally {
@@ -3166,7 +3229,7 @@ window.voiceIsolate = async function() {
3166
  showStatusOn("iso-status", "info", "Uploading audio...");
3167
  const fileRef = await uploadFile(RESEMBLE_BASE, isoFile);
3168
 
3169
- const { output, duration } = await gradioCall({
3170
  base: RESEMBLE_BASE,
3171
  fnIndex: 2,
3172
  data: [fileRef, "Midpoint", parseInt(isoNfe.value), isoTau.value / 100, denoiseOnly],
@@ -3182,9 +3245,9 @@ window.voiceIsolate = async function() {
3182
  resultUrls.iso = url;
3183
  document.getElementById("iso-audio-player").src = url;
3184
  document.getElementById("iso-player-title").textContent = denoiseOnly ? "Denoised audio" : "Enhanced audio";
3185
- document.getElementById("iso-player-meta").textContent = `${isoFile.name} · ${duration.toFixed(1)}s`;
3186
  result.classList.add("visible");
3187
- showStatusOn("iso-status", "success", `Done in ${duration.toFixed(1)}s`);
3188
  } catch (err) {
3189
  showStatusCtaOn("iso-status", err.message);
3190
  } finally {
@@ -3220,7 +3283,7 @@ window.soundEffect = async function() {
3220
  progressFill.style.width = "0%";
3221
 
3222
  try {
3223
- const { output, duration } = await gradioCall({
3224
  base: STABLE_AUDIO_BASE,
3225
  fnIndex: 3,
3226
  // /infer params: variant_key, prompt, duration, steps, cfg_scale, sampler_type, seed
@@ -3235,9 +3298,9 @@ window.soundEffect = async function() {
3235
  resultUrls.sfx = url;
3236
  document.getElementById("sfx-audio-player").src = url;
3237
  document.getElementById("sfx-player-title").textContent = prompt.slice(0, 60) + (prompt.length > 60 ? "..." : "");
3238
- document.getElementById("sfx-player-meta").textContent = `${sfxDuration.value}s · ${duration.toFixed(1)}s gen`;
3239
  result.classList.add("visible");
3240
- showStatusOn("sfx-status", "success", `Generated in ${duration.toFixed(1)}s`);
3241
  } catch (err) {
3242
  showStatusCtaOn("sfx-status", err.message);
3243
  } finally {
@@ -3284,6 +3347,7 @@ window.generate = async function() {
3284
  progressBar.classList.add("visible");
3285
  progressFill.style.width = "0%";
3286
 
 
3287
  try {
3288
  const blob = new Blob([selectedVoice.audioData], { type: selectedVoice.audioType || "audio/mpeg" });
3289
  const form = new FormData();
@@ -3346,14 +3410,14 @@ window.generate = async function() {
3346
  } else if (data.msg === "process_completed") {
3347
  progressFill.style.width = "100%";
3348
  if (data.success) {
3349
- const dur = data.output.duration || 0;
3350
  resultAudioUrl = data.output.data[0].url;
3351
  document.getElementById("audio-player").src = resultAudioUrl;
3352
  document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
3353
- document.getElementById("player-meta").textContent = `${selectedVoice.name} · ${dur.toFixed(1)}s · ${text.length} chars`;
3354
  result.classList.add("visible");
3355
- showStatus("success", `Generated in ${dur.toFixed(1)}s`);
3356
- addToHistory(text, selectedVoice.name, dur, resultAudioUrl);
3357
  } else {
3358
  const errMsg = data.output?.error || "Generation failed";
3359
  showStatusWithCTA(errMsg);
 
1745
  return "";
1746
  }
1747
 
1748
+ // Decode any recorded blob and re-encode as 16-bit PCM WAV so soundfile-based
1749
+ // upstreams (resemble-enhance, etc.) can read it.
1750
+ async function recordedBlobToWavFile(blob, baseName) {
1751
+ const Ctx = window.AudioContext || window.webkitAudioContext;
1752
+ const ctx = new Ctx();
1753
+ const arr = await blob.arrayBuffer();
1754
+ const audioBuf = await ctx.decodeAudioData(arr.slice(0));
1755
+ ctx.close?.();
1756
+ const numCh = audioBuf.numberOfChannels;
1757
+ const sampleRate = audioBuf.sampleRate;
1758
+ const numFrames = audioBuf.length;
1759
+ const dataLen = numFrames * numCh * 2;
1760
+ const buf = new ArrayBuffer(44 + dataLen);
1761
+ const view = new DataView(buf);
1762
+ let p = 0;
1763
+ const w8 = s => { for (let i = 0; i < s.length; i++) view.setUint8(p++, s.charCodeAt(i)); };
1764
+ const w32 = v => { view.setUint32(p, v, true); p += 4; };
1765
+ const w16 = v => { view.setUint16(p, v, true); p += 2; };
1766
+ w8("RIFF"); w32(36 + dataLen); w8("WAVE");
1767
+ w8("fmt "); w32(16); w16(1); w16(numCh); w32(sampleRate);
1768
+ w32(sampleRate * numCh * 2); w16(numCh * 2); w16(16);
1769
+ w8("data"); w32(dataLen);
1770
+ const channels = [];
1771
+ for (let c = 0; c < numCh; c++) channels.push(audioBuf.getChannelData(c));
1772
+ for (let i = 0; i < numFrames; i++) {
1773
+ for (let c = 0; c < numCh; c++) {
1774
+ const s = Math.max(-1, Math.min(1, channels[c][i]));
1775
+ view.setInt16(p, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
1776
+ p += 2;
1777
+ }
1778
+ }
1779
+ return new File([buf], `${baseName}.wav`, { type: "audio/wav" });
1780
+ }
1781
+
1782
  window.toggleRecord = async function() {
1783
  const btn = document.getElementById("record-mic-btn");
1784
  const hint = document.getElementById("record-hint");
 
1801
  mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream);
1802
  recordedChunks = [];
1803
  mediaRecorder.ondataavailable = e => { if (e.data && e.data.size) recordedChunks.push(e.data); };
1804
+ mediaRecorder.onstop = async () => {
1805
  clearInterval(recordTimerInterval);
1806
  btn.classList.remove("recording");
1807
  const elapsedSec = (Date.now() - recordStartTime) / 1000;
1808
  const type = mediaRecorder.mimeType || "audio/webm";
1809
+ const rawBlob = new Blob(recordedChunks, { type });
1810
+ hint.textContent = "Converting…";
1811
+ try {
1812
+ const wavFile = await recordedBlobToWavFile(rawBlob, `recording_${Date.now()}`);
1813
+ const wavBuf = await wavFile.arrayBuffer();
1814
+ modalFileData = { name: wavFile.name, data: wavBuf, type: "audio/wav" };
1815
  const preview = document.getElementById("record-preview");
1816
+ preview.src = URL.createObjectURL(wavFile);
1817
  preview.style.display = "";
1818
  hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
1819
  checkModalSave();
1820
+ } catch (err) {
1821
+ hint.textContent = `Recording failed: ${err.message}`;
1822
+ }
1823
  if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
1824
  };
1825
  recordStartTime = Date.now();
 
1984
  progressFill.style.width = "0%";
1985
 
1986
  const sessionHash = crypto.randomUUID();
1987
+ const t0 = Date.now();
1988
 
1989
  try {
1990
  const joinRes = await fetch(`${QWEN_BASE}/queue/join`, {
 
2027
  else if (data.msg === "process_completed") {
2028
  progressFill.style.width = "100%";
2029
  if (data.success) {
2030
+ const wallSec = (Date.now() - t0) / 1000;
2031
  designAudioUrl = data.output.data[0].url;
2032
  document.getElementById("design-audio-player").src = designAudioUrl;
2033
  document.getElementById("design-player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
2034
+ setPlayerMeta("design", "Voice Design", `${text.length} chars`);
2035
  result.classList.add("visible");
2036
+ showDesignStatus("success", `Generated in ${wallSec.toFixed(1)}s`);
2037
+ addToHistory(text, "Voice Design", wallSec, designAudioUrl);
2038
  } else {
2039
  const errMsg = data.output?.error || "Generation failed";
2040
  showDesignStatusWithCTA(errMsg);
 
2156
  const lang = document.getElementById("transcribe-lang").value;
2157
  const btn = transcribeBtn;
2158
  const result = document.getElementById("transcribe-result");
2159
+ const t0 = Date.now();
2160
 
2161
  btn.disabled = true;
2162
  btn.classList.add("loading");
 
2221
  const transcript = data.output.data[0];
2222
  document.getElementById("transcribe-text").textContent = transcript;
2223
  result.style.display = "block";
2224
+ showTranscribeStatus("success", `Transcribed in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
2225
  } else {
2226
  showTranscribeStatusWithCTA(data.output?.error || "Transcription failed");
2227
  }
 
2936
  document.getElementById(`${prefix}-timer`).textContent = `${Math.floor(e/60)}:${(e%60).toString().padStart(2,"0")}`;
2937
  }, 200);
2938
  inputRecorders[prefix] = { mediaRecorder: mr, stream, chunks, timerInterval, startTime };
2939
+ mr.onstop = async () => {
2940
  clearInterval(timerInterval);
2941
  btn.classList.remove("recording");
2942
  const elapsedSec = (Date.now() - startTime) / 1000;
2943
  const type = mr.mimeType || "audio/webm";
2944
+ const rawBlob = new Blob(chunks, { type });
2945
+ hint.textContent = "Converting…";
2946
+ try {
2947
+ const file = await recordedBlobToWavFile(rawBlob, `recording_${Date.now()}`);
2948
+ const preview = document.getElementById(`${prefix}-preview-audio`);
2949
+ preview.src = URL.createObjectURL(file);
2950
+ preview.style.display = "";
2951
+ hint.textContent = `Recorded ${elapsedSec.toFixed(1)}s · click mic to re-record`;
2952
+ inputRecordHandlers[prefix]?.(file);
2953
+ } catch (err) {
2954
+ hint.textContent = `Recording failed: ${err.message}`;
2955
+ } finally {
2956
+ stream.getTracks().forEach(t => t.stop());
2957
+ delete inputRecorders[prefix];
2958
+ }
2959
  };
2960
  hint.textContent = "Recording… click again to stop";
2961
  btn.classList.add("recording");
 
3051
  // Run a queue/join + queue/data SSE call. Returns { output, duration }.
3052
  // statusElId/progressFillId are optional and used for UI updates.
3053
  async function gradioCall({ base, fnIndex, data, statusElId, progressFillId, processingMsg }) {
3054
+ const t0 = Date.now();
3055
  const session = crypto.randomUUID();
3056
  if (statusElId) showStatusOn(statusElId, "info", "Joining queue...");
3057
 
 
3099
  }
3100
  if (evt.msg === "process_completed") {
3101
  if (progressFillId) document.getElementById(progressFillId).style.width = "100%";
3102
+ if (evt.success) {
3103
+ const wallClock = (Date.now() - t0) / 1000;
3104
+ return { output: evt.output, duration: evt.output.duration || wallClock, wallClock };
3105
+ }
3106
  throw new Error(evt.output?.error || "Generation failed");
3107
  }
3108
  }
 
3110
  throw new Error("Stream ended without completion");
3111
  }
3112
 
3113
+ // Update a player-meta line with the audio's actual duration once it loads.
3114
+ function setPlayerMeta(prefix, leadingText, trailingText) {
3115
+ const audioId = prefix ? `${prefix}-audio-player` : "audio-player";
3116
+ const metaId = prefix ? `${prefix}-player-meta` : "player-meta";
3117
+ const audio = document.getElementById(audioId);
3118
+ const meta = document.getElementById(metaId);
3119
+ if (!audio || !meta) return;
3120
+ const paint = () => {
3121
+ const d = isFinite(audio.duration) && audio.duration > 0 ? `${audio.duration.toFixed(1)}s` : "";
3122
+ meta.textContent = [leadingText, d, trailingText].filter(Boolean).join(" · ");
3123
+ };
3124
+ audio.addEventListener("loadedmetadata", paint, { once: true });
3125
+ paint();
3126
+ }
3127
+
3128
  // Pull a URL out of a gradio Audio output (object {url} or {path} ref).
3129
  function audioUrlFromOutput(out, base) {
3130
  if (!out) return null;
 
3167
  showStatusOn("vc-status", "info", "Uploading audio...");
3168
  const [src, tgt] = await Promise.all([uploadFile(SEED_VC_BASE, vcSource), uploadFile(SEED_VC_BASE, vcTarget)]);
3169
 
3170
+ const { output, wallClock } = await gradioCall({
3171
  base: SEED_VC_BASE,
3172
  fnIndex: 3,
3173
  data: [src, tgt, parseInt(vcSteps.value), 1.0, 0.0, vcSim.value / 100, 0.9, 1.0, 1.0, false, false],
 
3181
  if (!url) throw new Error("No audio returned");
3182
  resultUrls.vc = url;
3183
  document.getElementById("vc-audio-player").src = url;
3184
+ setPlayerMeta("vc", `${vcSource.name} → ${vcTarget.name}`);
3185
  result.classList.add("visible");
3186
+ showStatusOn("vc-status", "success", `Converted in ${wallClock.toFixed(1)}s`);
3187
  } catch (err) {
3188
  showStatusCtaOn("vc-status", err.message);
3189
  } finally {
 
3229
  showStatusOn("iso-status", "info", "Uploading audio...");
3230
  const fileRef = await uploadFile(RESEMBLE_BASE, isoFile);
3231
 
3232
+ const { output, wallClock } = await gradioCall({
3233
  base: RESEMBLE_BASE,
3234
  fnIndex: 2,
3235
  data: [fileRef, "Midpoint", parseInt(isoNfe.value), isoTau.value / 100, denoiseOnly],
 
3245
  resultUrls.iso = url;
3246
  document.getElementById("iso-audio-player").src = url;
3247
  document.getElementById("iso-player-title").textContent = denoiseOnly ? "Denoised audio" : "Enhanced audio";
3248
+ setPlayerMeta("iso", isoFile.name);
3249
  result.classList.add("visible");
3250
+ showStatusOn("iso-status", "success", `Done in ${wallClock.toFixed(1)}s`);
3251
  } catch (err) {
3252
  showStatusCtaOn("iso-status", err.message);
3253
  } finally {
 
3283
  progressFill.style.width = "0%";
3284
 
3285
  try {
3286
+ const { output, wallClock } = await gradioCall({
3287
  base: STABLE_AUDIO_BASE,
3288
  fnIndex: 3,
3289
  // /infer params: variant_key, prompt, duration, steps, cfg_scale, sampler_type, seed
 
3298
  resultUrls.sfx = url;
3299
  document.getElementById("sfx-audio-player").src = url;
3300
  document.getElementById("sfx-player-title").textContent = prompt.slice(0, 60) + (prompt.length > 60 ? "..." : "");
3301
+ setPlayerMeta("sfx", `Prompt: "${prompt.slice(0,40)}${prompt.length>40?"...":""}"`);
3302
  result.classList.add("visible");
3303
+ showStatusOn("sfx-status", "success", `Generated in ${wallClock.toFixed(1)}s`);
3304
  } catch (err) {
3305
  showStatusCtaOn("sfx-status", err.message);
3306
  } finally {
 
3347
  progressBar.classList.add("visible");
3348
  progressFill.style.width = "0%";
3349
 
3350
+ const t0 = Date.now();
3351
  try {
3352
  const blob = new Blob([selectedVoice.audioData], { type: selectedVoice.audioType || "audio/mpeg" });
3353
  const form = new FormData();
 
3410
  } else if (data.msg === "process_completed") {
3411
  progressFill.style.width = "100%";
3412
  if (data.success) {
3413
+ const wallSec = (Date.now() - t0) / 1000;
3414
  resultAudioUrl = data.output.data[0].url;
3415
  document.getElementById("audio-player").src = resultAudioUrl;
3416
  document.getElementById("player-title").textContent = text.slice(0, 60) + (text.length > 60 ? "..." : "");
3417
+ setPlayerMeta("", selectedVoice.name, `${text.length} chars`); // prefix="" → uses bare "audio-player"/"player-meta" ids
3418
  result.classList.add("visible");
3419
+ showStatus("success", `Generated in ${wallSec.toFixed(1)}s`);
3420
+ addToHistory(text, selectedVoice.name, wallSec, resultAudioUrl);
3421
  } else {
3422
  const errMsg = data.output?.error || "Generation failed";
3423
  showStatusWithCTA(errMsg);