ACloudCenter Claude Fable 5 commited on
Commit
05a8f68
·
1 Parent(s): 6462150

Offer an MP3 download alongside the WAV

Browse files

A 5.5-hour take is ~950MB of WAV; as 96kbps mono MP3 it's ~6x smaller
with no audible loss for 24kHz speech. The server encodes lazily on
first request (lameenc in a worker thread, cached per take, pruned with
the audio store) at /api/audio/{id}.mp3; the dock and stage grow a
Download MP3 button pointing there, shown only while the server still
holds the take.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. app.py +41 -0
  2. requirements.txt +1 -0
  3. static/app.js +13 -3
  4. static/index.html +4 -0
  5. static/styles.css +1 -0
app.py CHANGED
@@ -519,6 +519,7 @@ def _prune_audio_store() -> None:
519
  ]
520
  for k in stale:
521
  AUDIO_STORE.pop(k, None)
 
522
 
523
 
524
  # --- Abuse guardrails ---
@@ -803,6 +804,46 @@ async def api_last_take() -> dict:
803
  }
804
 
805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
  @app.get("/api/audio/{audio_id}")
807
  async def api_audio(audio_id: str) -> Response:
808
  entry = AUDIO_STORE.get(audio_id)
 
519
  ]
520
  for k in stale:
521
  AUDIO_STORE.pop(k, None)
522
+ MP3_CACHE.pop(k, None)
523
 
524
 
525
  # --- Abuse guardrails ---
 
804
  }
805
 
806
 
807
+ MP3_CACHE: dict[str, bytes] = {} # audio_id -> encoded mp3, built lazily on first request
808
+
809
+
810
+ def _encode_mp3(wav_bytes: bytes) -> bytes:
811
+ """Encode our PCM16 WAV to mono MP3 (96 kbps — transparent for 24kHz speech)."""
812
+ import lameenc
813
+
814
+ sample_rate, samples = wavfile.read(io.BytesIO(wav_bytes))
815
+ if samples.ndim > 1:
816
+ samples = samples[:, 0]
817
+ if samples.dtype != np.int16:
818
+ samples = np.asarray(samples, dtype=np.int16)
819
+ encoder = lameenc.Encoder()
820
+ encoder.set_bit_rate(96)
821
+ encoder.set_in_sample_rate(int(sample_rate))
822
+ encoder.set_channels(1)
823
+ encoder.set_quality(2)
824
+ mp3 = bytes(encoder.encode(samples.tobytes()))
825
+ mp3 += bytes(encoder.flush())
826
+ return mp3
827
+
828
+
829
+ @app.get("/api/audio/{audio_id}.mp3")
830
+ async def api_audio_mp3(audio_id: str) -> Response:
831
+ entry = AUDIO_STORE.get(audio_id)
832
+ if entry is None:
833
+ raise HTTPException(status_code=404, detail="Audio not found or expired.")
834
+ if audio_id not in MP3_CACHE:
835
+ _, wav_bytes = entry
836
+ # Encoding hours of audio takes minutes of CPU — keep the event loop free.
837
+ MP3_CACHE[audio_id] = await asyncio.get_event_loop().run_in_executor(
838
+ None, _encode_mp3, wav_bytes
839
+ )
840
+ return Response(
841
+ content=MP3_CACHE[audio_id],
842
+ media_type="audio/mpeg",
843
+ headers={"Content-Disposition": 'attachment; filename="conference.mp3"'},
844
+ )
845
+
846
+
847
  @app.get("/api/audio/{audio_id}")
848
  async def api_audio(audio_id: str) -> Response:
849
  entry = AUDIO_STORE.get(audio_id)
requirements.txt CHANGED
@@ -5,3 +5,4 @@ modal
5
  huggingface_hub
6
  numpy
7
  scipy
 
 
5
  huggingface_hub
6
  numpy
7
  scipy
8
+ lameenc
static/app.js CHANGED
@@ -82,6 +82,7 @@ const el = {};
82
  "stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
83
  "stageScriptToggle", "stageTranscript",
84
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
 
85
  "logToggleBtn", "logBox",
86
  "voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
87
  "cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
@@ -1588,12 +1589,21 @@ async function downloadTakeBlob(audioId) {
1588
  return new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
1589
  }
1590
 
1591
- async function presentTake(blob, durationSeconds, snapshot) {
1592
  setStatus("complete");
1593
  const url = URL.createObjectURL(blob);
1594
  el.resultAudio.src = url;
1595
  el.downloadBtn.href = url;
1596
  el.stageDownloadBtn.href = url;
 
 
 
 
 
 
 
 
 
1597
  el.audioDuration.textContent = formatDuration(durationSeconds);
1598
  el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
1599
  el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
@@ -1641,7 +1651,7 @@ async function checkLastTake() {
1641
  el.generationTime.textContent = "--";
1642
  el.resultModel.textContent = "recovered";
1643
  state.resultTitle = "Recovered take";
1644
- await presentTake(blob, info.duration, []);
1645
  } catch (error) {
1646
  setStatus("error", error.message);
1647
  btn.disabled = false;
@@ -1762,7 +1772,7 @@ el.generateBtn.addEventListener("click", async () => {
1762
  el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
1763
  el.resultModel.textContent = state.model;
1764
  state.resultTitle = el.scriptTitle.textContent;
1765
- await presentTake(blob, evt.audio_duration, turnsSnapshot);
1766
  }
1767
  }
1768
  }
 
82
  "stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
83
  "stageScriptToggle", "stageTranscript",
84
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
85
+ "downloadMp3Btn", "stageDownloadMp3Btn",
86
  "logToggleBtn", "logBox",
87
  "voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
88
  "cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
 
1589
  return new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
1590
  }
1591
 
1592
+ async function presentTake(blob, durationSeconds, snapshot, audioId) {
1593
  setStatus("complete");
1594
  const url = URL.createObjectURL(blob);
1595
  el.resultAudio.src = url;
1596
  el.downloadBtn.href = url;
1597
  el.stageDownloadBtn.href = url;
1598
+ // MP3 comes from the server (encoded lazily there) — only offer it while
1599
+ // the server still holds this take.
1600
+ const mp3Url = audioId ? `/api/audio/${audioId}.mp3` : null;
1601
+ el.downloadMp3Btn.hidden = !mp3Url;
1602
+ el.stageDownloadMp3Btn.hidden = !mp3Url;
1603
+ if (mp3Url) {
1604
+ el.downloadMp3Btn.href = mp3Url;
1605
+ el.stageDownloadMp3Btn.href = mp3Url;
1606
+ }
1607
  el.audioDuration.textContent = formatDuration(durationSeconds);
1608
  el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
1609
  el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
 
1651
  el.generationTime.textContent = "--";
1652
  el.resultModel.textContent = "recovered";
1653
  state.resultTitle = "Recovered take";
1654
+ await presentTake(blob, info.duration, [], info.audio_id);
1655
  } catch (error) {
1656
  setStatus("error", error.message);
1657
  btn.disabled = false;
 
1772
  el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
1773
  el.resultModel.textContent = state.model;
1774
  state.resultTitle = el.scriptTitle.textContent;
1775
+ await presentTake(blob, evt.audio_duration, turnsSnapshot, evt.audio_id);
1776
  }
1777
  }
1778
  }
static/index.html CHANGED
@@ -173,6 +173,8 @@
173
  <div><span>Model</span> <strong id="resultModel">--</strong></div>
174
  </div>
175
  <a id="downloadBtn" class="btn btn-pill-outline" download="chorus-audio.wav">Download WAV</a>
 
 
176
  <audio id="resultAudio" hidden></audio>
177
  </div>
178
 
@@ -200,6 +202,8 @@
200
  </div>
201
  <div class="stage-actions">
202
  <a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
 
 
203
  <button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
204
  </div>
205
  <div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
 
173
  <div><span>Model</span> <strong id="resultModel">--</strong></div>
174
  </div>
175
  <a id="downloadBtn" class="btn btn-pill-outline" download="chorus-audio.wav">Download WAV</a>
176
+ <a id="downloadMp3Btn" class="btn btn-pill-outline" download="chorus-audio.mp3"
177
+ title="Much smaller file — the server encodes it on first click, which can take a minute or two for long takes">Download MP3 (smaller)</a>
178
  <audio id="resultAudio" hidden></audio>
179
  </div>
180
 
 
202
  </div>
203
  <div class="stage-actions">
204
  <a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
205
+ <a class="btn btn-pill-outline" id="stageDownloadMp3Btn" download="chorus-audio.mp3"
206
+ title="Much smaller file — encoded on the server on first click">MP3</a>
207
  <button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
208
  </div>
209
  <div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
static/styles.css CHANGED
@@ -466,6 +466,7 @@ select:focus { outline: none; border-color: #d9a98c; }
466
  }
467
  .btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
468
  .btn-accent:disabled { background: #d9a98c; border-color: #d9a98c; opacity: 1; }
 
469
  .btn-pill-outline {
470
  display: block;
471
  text-align: center;
 
466
  }
467
  .btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
468
  .btn-accent:disabled { background: #d9a98c; border-color: #d9a98c; opacity: 1; }
469
+ .result-block .btn-pill-outline { margin-bottom: 8px; }
470
  .btn-pill-outline {
471
  display: block;
472
  text-align: center;