← back to Rentv
feat(brief): Boomer 'Deal of the Day' audio brief (REPOINT feature #3, Steve-approved) — gen-deal-brief.mjs composes a factual script from the top deal (reuses acquirer) + ElevenLabs TTS in Boomer's voice (czUPGx5phxE6qpQ46nRS), with a top-deal-change spend guard (~$0.15/day not per-cron) + DRY_RUN; brief.html gains a graceful audio player. ALSO fixes a real summarize() bug the brief surfaced: the sentence splitter broke decimals ('$1.625'→'$1. 625') on every deal card — now splits only on a terminator+space+capital. +1 test, 64/0. Generated 1 brief ($0.147, local).
48878dbe4c9ef2e172e5db43153cbb245d6d1031 · 2026-08-06 16:09:24 -0700 · Steve
Files touched
M .gitignoreM public/brief.htmlA scripts/gen-deal-brief.mjsM scripts/lib/deal-parse.mjsM test/deals/parse.test.mjs
Diff
commit 48878dbe4c9ef2e172e5db43153cbb245d6d1031
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 16:09:24 2026 -0700
feat(brief): Boomer 'Deal of the Day' audio brief (REPOINT feature #3, Steve-approved) — gen-deal-brief.mjs composes a factual script from the top deal (reuses acquirer) + ElevenLabs TTS in Boomer's voice (czUPGx5phxE6qpQ46nRS), with a top-deal-change spend guard (~$0.15/day not per-cron) + DRY_RUN; brief.html gains a graceful audio player. ALSO fixes a real summarize() bug the brief surfaced: the sentence splitter broke decimals ('$1.625'→'$1. 625') on every deal card — now splits only on a terminator+space+capital. +1 test, 64/0. Generated 1 brief ($0.147, local).
---
.gitignore | 3 ++
public/brief.html | 21 +++++++++++
scripts/gen-deal-brief.mjs | 93 ++++++++++++++++++++++++++++++++++++++++++++++
scripts/lib/deal-parse.mjs | 6 ++-
test/deals/parse.test.mjs | 7 ++++
5 files changed, 129 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index e489498b..eda8a131 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,6 @@ data/search-index.jsonl
data/search-index-meta.json
ops/*.log
ops/*.err
+
+# Generated Boomer audio briefs (regenerable, rsync-excluded like other runtime data)
+public/audio/
diff --git a/public/brief.html b/public/brief.html
index 512ff3dd..863fed17 100644
--- a/public/brief.html
+++ b/public/brief.html
@@ -112,6 +112,27 @@
<a href="/subscribe"><button>✉️ Subscribe</button></a>
</div>
+ <!-- Boomer "Deal of the Day" audio brief — hidden until an mp3 exists (graceful empty state) -->
+ <div class="boomcast" id="boomcast" style="display:none;margin:18px 0;padding:15px 18px;border:1px solid var(--line,#e4e7ea);border-radius:14px;background:linear-gradient(180deg,#fff,#fafbfc)">
+ <div style="display:flex;align-items:center;gap:13px;flex-wrap:wrap">
+ <div style="font-size:26px" aria-hidden="true">🎧</div>
+ <div style="flex:1;min-width:180px">
+ <div style="font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#c8102e">Boomer's Deal of the Day</div>
+ <a id="bc-title" href="/deals" style="display:block;font-weight:700;font-size:15px;margin-top:2px;color:inherit;text-decoration:none">—</a>
+ </div>
+ <audio id="bc-audio" controls preload="none" style="height:38px;max-width:100%"></audio>
+ </div>
+ </div>
+ <script>(function(){
+ fetch('/audio/deal-brief.json',{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(m){
+ if(!m||m.dry_run) return;
+ var el=document.getElementById('boomcast'); if(!el) return;
+ var t=document.getElementById('bc-title'); t.textContent=m.title||'Today’s top deal'; if(m.url)t.href=m.url;
+ document.getElementById('bc-audio').src='/audio/deal-brief.mp3';
+ el.style.display='block';
+ }).catch(function(){});
+ })();</script>
+
<div class="snapshot" id="snapshot"></div>
<div class="sec"><div class="rule">Top Story</div><div id="lead"></div></div>
diff --git a/scripts/gen-deal-brief.mjs b/scripts/gen-deal-brief.mjs
new file mode 100644
index 00000000..c1982bf6
--- /dev/null
+++ b/scripts/gen-deal-brief.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+// gen-deal-brief.mjs — Boomer "Deal of the Day" audio brief (REPOINT feature #3, Steve-approved 2026-08-06).
+// Picks the top deal by amount, composes a FACTUAL ~450-char script (reusing verified deal fields incl. the
+// new `acquirer`), synthesizes it in Boomer's cloned voice via ElevenLabs, caches the mp3 + metadata.
+//
+// SPEND GUARD (the memo's key guardrail): regenerates ONLY when the top deal CHANGES (dedupe on deal id) —
+// never per-cron — so daily cost stays ~$0.15, not ~$10. `DRY_RUN=1` composes the script + writes metadata
+// with NO TTS call ($0). `--force` overrides the dedupe.
+//
+// Boomer is the on-air PERSONA ("This is Boom…") — never "Bloom" in any customer-facing text. The script is
+// factual-only (no editorializing) so an auto-published brief can't misstate a deal.
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const DATA = join(ROOT, 'data');
+const AUDIO = join(ROOT, 'public', 'audio');
+const VOICE_ID = process.env.RENTV_VOICE_ID || 'czUPGx5phxE6qpQ46nRS';
+const RATE_PER_1K = 0.30; // ElevenLabs Creator-tier estimate, $/1000 chars
+const FORCE = process.argv.includes('--force');
+
+function elevenKey() {
+ if (process.env.ELEVENLABS_API_KEY) return process.env.ELEVENLABS_API_KEY.trim().replace(/^["']|["']$/g, '');
+ const candidates = [join(ROOT, '.env'), join(process.env.HOME || '', 'Projects/secrets-manager/.env')];
+ for (const f of candidates) {
+ try { const m = readFileSync(f, 'utf8').match(/^ELEVENLABS_API_KEY=(.+)$/m); if (m) return m[1].trim().replace(/^["']|["']$/g, ''); } catch {}
+ }
+ return '';
+}
+
+function pickTopDeal(deals) {
+ return deals.filter(d => typeof d.amount === 'number' && d.amount > 0).sort((a, b) => b.amount - a.amount)[0] || null;
+}
+
+// Factual one-paragraph brief keyed to the verified deal fields — no invented detail.
+export function script(d) {
+ const who = (d.acquirer && String(d.acquirer).trim()) || 'A major investor';
+ const amt = d.amount_label || (d.amount ? `$${Math.round(d.amount / 1e6)} million` : '');
+ const mkt = [d.city, d.state].filter(Boolean).join(', ');
+ const type = d.property_type ? String(d.property_type).toLowerCase() : 'commercial real estate';
+ // Defensive: repair a decimal that a stale summary may have split ("$1. 625" → "$1.625") so the
+ // synthesized audio never speaks a broken number. (The root fix is in summarize(); this guards TTS
+ // input against pre-fix cached data.)
+ const summary = d.summary ? String(d.summary).replace(/(\d)\.\s+(?=\d)/g, '$1.') : '';
+ let s = `This is Boom with RENTV, and here's today's top deal. ${d.title}.`;
+ if (amt) s += ` ${who} in a ${amt} transaction${mkt ? ` in ${mkt}` : ''}.`;
+ if (summary) s += ` ${summary}`;
+ s += ` It's the kind of ${type} activity moving the Western U.S. market. For the full story, visit rentv dot com.`;
+ return s.replace(/\s+/g, ' ').trim().slice(0, 900); // hard length cap = cost cap
+}
+
+async function tts(text, out) {
+ const key = elevenKey();
+ if (!key) throw new Error('ELEVENLABS_API_KEY missing');
+ const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128`, {
+ method: 'POST',
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.15 } }),
+ });
+ if (!res.ok) throw new Error(`ElevenLabs ${res.status}: ${(await res.text()).slice(0, 200)}`);
+ writeFileSync(out, Buffer.from(await res.arrayBuffer()));
+}
+
+async function main() {
+ const deals = JSON.parse(readFileSync(join(DATA, 'deals.json'), 'utf8')).deals || [];
+ const top = pickTopDeal(deals);
+ if (!top) { console.log('no priced deal — skip (nothing to narrate)'); return; }
+ if (!existsSync(AUDIO)) mkdirSync(AUDIO, { recursive: true });
+ const metaPath = join(AUDIO, 'deal-brief.json');
+ const mp3Path = join(AUDIO, 'deal-brief.mp3');
+ const prev = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, 'utf8')) : null;
+ // Skip (no spend) only if the SAME top deal already has a REAL mp3 on disk — a prior DRY_RUN (no audio)
+ // must not poison the cache into skipping the real synthesis.
+ if (prev && prev.deal_id === top.id && !prev.dry_run && existsSync(mp3Path) && !FORCE) {
+ console.log(`top deal unchanged (${top.id}) + mp3 exists — SKIP, no spend`); return;
+ }
+
+ const text = script(top);
+ const chars = text.length;
+ const est = (chars / 1000) * RATE_PER_1K;
+ console.log(`top deal ${top.id}: "${top.title.slice(0, 54)}"`);
+ console.log(`script (${chars} chars): ${text}`);
+ console.log(`est cost: $${est.toFixed(3)} (${chars} chars @ $${RATE_PER_1K}/1k)`);
+
+ const meta = { deal_id: top.id, title: top.title, acquirer: top.acquirer || null, amount_label: top.amount_label || null, url: top.url || null, script: text, chars, generated_at: new Date().toISOString() };
+ if (process.env.DRY_RUN) { meta.dry_run = true; writeFileSync(metaPath, JSON.stringify(meta, null, 1)); console.log('DRY_RUN=1 → wrote metadata, NO TTS call, $0'); return; }
+ await tts(text, join(AUDIO, 'deal-brief.mp3'));
+ writeFileSync(metaPath, JSON.stringify(meta, null, 1));
+ console.log(`✓ /public/audio/deal-brief.mp3 written · actual ~$${est.toFixed(3)}`);
+}
+
+main().catch(e => { console.error('gen-deal-brief failed:', e.message); process.exit(1); });
diff --git a/scripts/lib/deal-parse.mjs b/scripts/lib/deal-parse.mjs
index 359cd7ea..1976115f 100644
--- a/scripts/lib/deal-parse.mjs
+++ b/scripts/lib/deal-parse.mjs
@@ -159,7 +159,11 @@ export function summarize(title, body) {
const ti = b.indexOf(title);
if (ti >= 0) b = b.slice(ti + title.length);
b = b.replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim();
- const sentences = b.match(/[^.!?]+[.!?]+/g) || [b];
+ // Split on a terminator followed by whitespace + a capital/quote — a REAL sentence boundary — so a
+ // decimal ("$1.625 bil") or abbreviation ("U.S.") is not mistaken for a sentence end. The old
+ // /[^.!?]+[.!?]+/g split "$1.625" into "$1." + "625" (customer-facing "$1. 625 bil"). Falls back to the
+ // whole body when there's no clean boundary.
+ const sentences = b.split(/(?<=[.!?])\s+(?=[A-Z"'“])/).filter(Boolean);
// Collapse whitespace runs to a single space: the sentence class [^.!?]+ greedily absorbs the
// space(s) AFTER the prior sentence's period, so each captured sentence carries leading whitespace
// and join(' ') then yields "end. Start" (2–3 spaces). 30% of the live corpus's summaries showed
diff --git a/test/deals/parse.test.mjs b/test/deals/parse.test.mjs
index 7e27c0a9..ae7cfa58 100644
--- a/test/deals/parse.test.mjs
+++ b/test/deals/parse.test.mjs
@@ -191,6 +191,13 @@ test('parseAcquirer: null on ASSET-fronted / place-fronted / passive titles (nev
test('summarize: empty body → empty string (never throws)', () => {
assert.equal(summarize('Any Title', ''), '');
});
+test('summarize: a decimal / abbreviation is NOT split as a sentence boundary', () => {
+ // "$1.625" and "U.S." used to split the decimal → "$1. 625 bil" on the deal card (surfaced by the audio brief).
+ assert.equal(
+ summarize('X', 'X A vehicle acquired an 11-property portfolio for $1.625 bil in the U.S. market. Next detail here.'),
+ 'A vehicle acquired an 11-property portfolio for $1.625 bil in the U.S. market. Next detail here.',
+ );
+});
test('summarize: collapses multi-space / tab / newline runs from the fetched body (30% of live corpus)', () => {
// The body itself carries double spaces + a newline (real fetched-article artifact); output must be clean.
assert.equal(
← 21845887 auto-data-snapshot: 2026-08-06T16:03:07 (7 data files) — dat
·
back to Rentv
·
nav: surface /firm (💼 Active Dealmakers) in the site-wide E fd1945d5 →