← 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
M .gitignoreM package.jsonM public/today.htmlA src/jobs/daily_standup_audio.tsM src/server/index.ts
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →