[object Object]

← back to Ventura Corridor

iter 140: daily-standup audio summary — Samantha TTS + m4a + /standup viewer + 7:50 AM launchd

548f05d2d6f85217778ce0b32c6075f9577c520d · 2026-05-06 19:03:52 -0700 · SteveStudio2

Files touched

Diff

commit 548f05d2d6f85217778ce0b32c6075f9577c520d
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 19:03:52 2026 -0700

    iter 140: daily-standup audio summary — Samantha TTS + m4a + /standup viewer + 7:50 AM launchd
---
 .gitignore                      |   2 +
 package.json                    |   3 +-
 public/today.html               |   1 +
 src/jobs/daily_standup_audio.ts | 107 ++++++++++++++++++++++++++++++++++++++++
 src/server/index.ts             |  47 ++++++++++++++++++
 5 files changed, 159 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 0cb9eaa..26201aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,7 @@ node_modules/
 logs/
 data/raw/
 data/screenshots/
+data/audio/
+tmp/
 *.log
 .DS_Store
diff --git a/package.json b/package.json
index ad8be1e..5681762 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,8 @@
     "magazine:morning-email": "tsx src/jobs/morning_feature_email.ts",
     "magazine:auto-publish": "tsx src/jobs/auto_publish_top.ts",
     "magazine:regen-thin": "tsx src/jobs/regen_thin.ts",
-    "magazine:ig-dryrun": "tsx src/jobs/ig_autopost_dryrun.ts"
+    "magazine:ig-dryrun": "tsx src/jobs/ig_autopost_dryrun.ts",
+    "magazine:standup": "tsx src/jobs/daily_standup_audio.ts"
   },
   "dependencies": {
     "archiver": "^7.0.1",
diff --git a/public/today.html b/public/today.html
index b46293a..8ee60d1 100644
--- a/public/today.html
+++ b/public/today.html
@@ -122,6 +122,7 @@
     <a href="/scoreboard.html">🏆 score</a>
     <a href="/feedback.html" id="nav-feedback" style="color:var(--ink-mute)">✎ feedback</a>
     <a href="/admin/ig-drafts/" target="_blank">📷 ig drafts</a>
+    <a href="/standup">🎙 standup</a>
     <a href="/crawl-derby.html">🏇 derby</a>
     <a href="/today.html" class="active">today</a>
     <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
diff --git a/src/jobs/daily_standup_audio.ts b/src/jobs/daily_standup_audio.ts
new file mode 100644
index 0000000..9b08489
--- /dev/null
+++ b/src/jobs/daily_standup_audio.ts
@@ -0,0 +1,107 @@
+// Renders a ~60 second daily-standup audio summary of yesterday's pipeline activity.
+// Uses macOS `say -v Samantha` + afconvert (AAC m4a) — same toolchain as feature audio.
+// Output:  ~/Projects/ventura-corridor/data/audio/standup/standup-YYYY-MM-DD.m4a
+//          + a /standup/<date> route on the server (separate route, see admin gate)
+//
+// Run manually:  npm run magazine:standup
+// Cron-driven:   launchd at 7:50 AM (just before morning email)
+
+import { execSync, spawnSync } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { Pool } from 'pg';
+
+const pool = new Pool({ host: '/tmp', database: 'ventura_corridor' });
+
+async function q<T = any>(sql: string, params: any[] = []): Promise<{ rows: T[] }> {
+  const r = await pool.query(sql, params);
+  return { rows: r.rows };
+}
+
+function fmtN(n: number) { return n.toLocaleString('en-US'); }
+
+async function buildScript(): Promise<string> {
+  const today = new Date().toISOString().slice(0, 10);
+  const day = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
+
+  // Daily stats
+  const stats = (await q(`
+    SELECT
+      (SELECT count(*) FROM magazine_features WHERE generated_at > NOW() - INTERVAL '24 hours') AS gen_24h,
+      (SELECT count(*) FROM magazine_features WHERE published_at > NOW() - INTERVAL '24 hours') AS pub_24h,
+      (SELECT count(*) FROM magazine_features WHERE status = 'published') AS total_pub,
+      (SELECT count(*) FROM magazine_features) AS total_features,
+      (SELECT count(*) FROM businesses) AS total_biz,
+      (SELECT COALESCE(SUM(taps), 0) FROM magazine_features) AS total_taps,
+      (SELECT count(*) FROM reader_feedback WHERE received_at > NOW() - INTERVAL '24 hours') AS feedback_24h,
+      (SELECT count(*) FROM reader_feedback WHERE NOT reviewed) AS feedback_unread,
+      (SELECT count(*) FROM sponsor_inquiries WHERE received_at > NOW() - INTERVAL '24 hours') AS inq_24h,
+      (SELECT count(*) FROM sponsor_inquiries WHERE status = 'new') AS inq_open
+  `)).rows[0];
+
+  // Top story by taps in last 24h
+  const top = (await q<{ headline: string; taps: number; biz: string }>(`
+    SELECT mf.headline, mf.taps, b.name AS biz
+    FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
+    WHERE mf.status = 'published' AND mf.published_at > NOW() - INTERVAL '7 days'
+    ORDER BY mf.taps DESC NULLS LAST, mf.views DESC NULLS LAST LIMIT 1
+  `)).rows[0];
+
+  // Cover this month
+  const cover = (await q<{ headline: string; biz: string }>(`
+    SELECT mf.headline, b.name AS biz
+    FROM magazine_issues i
+    JOIN magazine_features mf ON mf.id = i.cover_feature_id
+    JOIN businesses b ON b.id = mf.business_id
+    WHERE i.issue_month = $1
+  `, [today.slice(0, 7)])).rows[0];
+
+  const lines: string[] = [];
+  lines.push(`Good morning, Steve. It's ${day}, and here is the standup for The Corridor.`);
+  lines.push(``);
+  lines.push(`In the last 24 hours, the editorial assistant generated ${fmtN(Number(stats.gen_24h))} new feature${Number(stats.gen_24h) === 1 ? '' : 's'}, and you, or the auto-publisher, sent ${fmtN(Number(stats.pub_24h))} to the published shelf.`);
+  lines.push(`The issue now stands at ${fmtN(Number(stats.total_pub))} published feature${Number(stats.total_pub) === 1 ? '' : 's'}, with ${fmtN(Number(stats.total_features))} drafts in the pipeline, drawn from a corpus of ${fmtN(Number(stats.total_biz))} businesses on the boulevard.`);
+  if (top) lines.push(`The most-tapped story this week was "${top.headline}", a feature on ${top.biz}, with ${fmtN(Number(top.taps || 0))} reader tap${Number(top.taps) === 1 ? '' : 's'}.`);
+  if (cover) lines.push(`On the cover this month: "${cover.headline}", on ${cover.biz}.`);
+  if (Number(stats.feedback_24h) > 0) {
+    lines.push(`Readers sent ${fmtN(Number(stats.feedback_24h))} note${Number(stats.feedback_24h) === 1 ? '' : 's'} to the desk in the last day. ${fmtN(Number(stats.feedback_unread))} ${Number(stats.feedback_unread) === 1 ? 'is' : 'are'} unread on the feedback page.`);
+  }
+  if (Number(stats.inq_24h) > 0) {
+    lines.push(`Sponsorship: ${fmtN(Number(stats.inq_24h))} new inquir${Number(stats.inq_24h) === 1 ? 'y' : 'ies'} in the last 24 hours, ${fmtN(Number(stats.inq_open))} still open.`);
+  }
+  lines.push(``);
+  lines.push(`Have a productive morning.`);
+  return lines.join(' ');
+}
+
+async function main() {
+  const today = new Date().toISOString().slice(0, 10);
+  const dir = path.resolve(process.cwd(), 'data', 'audio', 'standup');
+  fs.mkdirSync(dir, { recursive: true });
+
+  const script = await buildScript();
+  const txtPath = path.join(dir, `standup-${today}.txt`);
+  const aiffPath = path.join(dir, `standup-${today}.aiff`);
+  const m4aPath = path.join(dir, `standup-${today}.m4a`);
+  fs.writeFileSync(txtPath, script);
+
+  console.log(`[standup] script ${script.length} chars`);
+
+  const say = spawnSync('say', ['-v', 'Samantha', '-r', '180', '-o', aiffPath, script], { stdio: 'inherit' });
+  if (say.status !== 0) throw new Error('say failed');
+  const conv = spawnSync('afconvert', ['-d', 'aac', '-b', '64000', '-f', 'm4af', aiffPath, m4aPath], { stdio: 'inherit' });
+  if (conv.status !== 0) throw new Error('afconvert failed');
+  const m4aSize = fs.statSync(m4aPath).size;
+  fs.unlinkSync(aiffPath);
+
+  console.log(`[standup] wrote ${m4aPath} (${(m4aSize/1024).toFixed(1)}KB)`);
+
+  // Symlink "latest"
+  const latest = path.join(dir, 'latest.m4a');
+  try { fs.unlinkSync(latest); } catch {}
+  fs.symlinkSync(`standup-${today}.m4a`, latest);
+
+  await pool.end();
+}
+
+main().catch(e => { console.error('[standup]', e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 44d1f92..f26a0ad 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -93,6 +93,7 @@ const ADMIN_PATHS = [
   /^\/feedback(\.html)?\/?$/i,
   /^\/admin\/ig-drafts(\/.*)?$/i,
   /^\/api\/issues(\/.*)?$/i,
+  /^\/standup(\/.*)?$/i,
   /^\/sponsor\/\d+\/?$/i,
   /^\/api\/sponsor(\/.*)?$/i,
   /^\/crawl-derby(\.html)?\/?$/i,
@@ -4094,6 +4095,52 @@ app.use('/admin/ig-drafts', express.static(path.resolve(__dirname, '../../tmp/ig
   index: 'index.html',
 }));
 
+// ─── Daily standup audio — generated by daily_standup_audio job ──
+app.use('/standup/audio', express.static(path.resolve(__dirname, '../../data/audio/standup'), { maxAge: '1m' }));
+app.get('/standup', async (_req, res) => {
+  const dir = path.resolve(__dirname, '../../data/audio/standup');
+  const fs = await import('node:fs');
+  let entries: string[] = [];
+  try { entries = fs.readdirSync(dir).filter(n => /^standup-\d{4}-\d{2}-\d{2}\.m4a$/.test(n)).sort().reverse(); } catch {}
+  const today = entries[0] || null;
+  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+  let scriptBody = '';
+  if (today) {
+    try { scriptBody = fs.readFileSync(path.join(dir, today.replace('.m4a', '.txt')), 'utf8'); } catch {}
+  }
+  res.type('html').send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>Daily standup · The Corridor</title>
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+*{box-sizing:border-box}body{margin:0;background:#0a0a0c;color:#f0ece2;font-family:Inter,sans-serif;font-weight:300;padding:48px 24px;max-width:720px;margin:0 auto}
+h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:48px;margin:0 0 6px;color:#f0ece2}
+h1 em{color:#b89968;font-style:normal}
+.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500;margin-bottom:14px}
+.player{margin:28px 0;padding:24px;border:1px solid #2a2622;background:rgba(184,153,104,0.04)}
+.player .meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:#888475;margin-bottom:14px}
+audio{width:100%;border-radius:0}
+.script{margin-top:20px;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;line-height:1.6;color:#f0ece2;background:rgba(184,153,104,0.04);border:1px solid #2a2622;padding:24px;white-space:pre-wrap}
+.archive{margin-top:36px}
+.archive h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:#b89968;margin:0 0 14px}
+.archive a{display:block;font-family:'JetBrains Mono',monospace;font-size:12px;color:#888475;text-decoration:none;padding:6px 0;border-bottom:1px solid #2a2622}
+.archive a:hover{color:#b89968}
+nav.top{font-size:9px;letter-spacing:.32em;text-transform:uppercase;margin-bottom:24px}
+nav.top a{color:#888475;text-decoration:none;margin-right:18px}
+nav.top a:hover{color:#b89968}
+</style></head><body>
+<nav class="top"><a href="/today.html">← today</a><a href="/issue">issue</a><a href="/feedback.html">feedback</a></nav>
+<div class="kicker">${new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}</div>
+<h1>Daily <em>standup</em></h1>
+${today ? `<div class="player">
+  <div class="meta">${esc(today)}</div>
+  <audio controls preload="auto" autoplay>
+    <source src="/standup/audio/${esc(today)}" type="audio/mp4">
+  </audio>
+</div>${scriptBody ? `<div class="script">${esc(scriptBody)}</div>` : ''}
+<div class="archive"><h3>Archive</h3>${entries.slice(1, 14).map(e => `<a href="/standup/audio/${esc(e)}">${esc(e)}</a>`).join('') || '<div style="color:#888475;font-style:italic;font-family:Cormorant Garamond,serif">no prior standups yet</div>'}</div>` : `<div class="player"><div class="meta">No standup audio yet. Run <code style="color:#b89968">npm run magazine:standup</code> or wait for the 7:50 AM job.</div></div>`}
+</body></html>`);
+});
+
 // ─── Static viewer ─────────────────────────────────────────────────────
 app.use('/', express.static(path.resolve(__dirname, '../../public')));
 

← 4aa9741 iter 139: /issue.epub export — pure-zip EPUB 2 with cover, T  ·  back to Ventura Corridor  ·  iter 141: category emoji map on /scoreboard — 36 verticals m 6ffa8b0 →