← back to Ventura Corridor
src/jobs/daily_standup_audio.ts
108 lines
// 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); });