This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend
TL;DR
- Built for Nathan: best friend of 20+ years, musician behind Flies Like Robots, not a marketing guy.
- Self-hosted Gemma 4 watches and listens to his video, then drafts the YouTube metadata and Bandcamp copy in his voice, and picks a hook for a vertical Short.
- He edits or approves. Soundboard remembers the edit, uploads as private, and reads it back from YouTube before calling it verified.
- Code • 60 s demo • Deployed site
What I Built
Nathan makes the music and the videos. He's been one of my best friends for more than 20 years, so I can say this with some authority: he is a musician, and asking him to also be in marketing is seriously pushing it.
Before it drafts anything, Gemma reads Nathan's 30 latest videos and 10 thumbnails, then proposes a brand guide with keep, fix, and drop lists for him to edit or approve. That approved guide takes priority over patterns in his older uploads, because reading his channel shouldn't mean repeating everything he's trying to improve. I want the copy to sound like Nathan, and “write me a YouTube title” gives the model nothing to work with.
Soundboard covers the gap between a finished video and somebody finding it. He uploads the video and types the song title. Gemma drafts the YouTube title, description, and tags, grounded in what's reaching listeners in that genre and written the way Nathan writes. It drafts the Bandcamp About text and credits too, with a Copy button for each, because Bandcamp has no upload API and won't be framed. And it picks the hook for a vertical Short, which ffmpeg cuts to 9:16. He edits, re-runs, or approves. Every edit becomes evidence for the next draft.
I can't promise an algorithm will get his music heard. I can take the busywork off him.
His music: YouTube • Bandcamp.
Demo
One sample, start to finish: upload, Gemma's draft, an edit, approval, and the private upload verified against YouTube. To run it locally, follow the README's GCP setup, pull the Gemma 4 GGUF for llama-server, then make dev.
Sign in with username anchildress1demo@gmail.com and password xAf(nH*2Qf1T to access the YouTube sandbox. You can also sign into YouTube directly to validate your upload.
Deployed at https://soundboard.anchildress1.dev.
Code
anchildress1
/
soundboard
Release agent for one musician: self-hosted Gemma 4 watches and listens to a music video, then drafts and verifies the YouTube upload.
🎛️ Soundboard
Soundboard analyzes a finished music video and drafts its YouTube title, description, hashtags, and tags. The artist approves, edits, or re-runs the draft; on approval, Soundboard uploads the video as private and confirms the upload through the YouTube API.
Built for Flies Like Robots as an entry in the DEV Hacktoberfest Weekend Challenge: Build for a Friend. Try it at soundboard.anchildress1.dev, and read the submission.
Table of Contents
- About
- Features
- Tech Stack
- Architecture
- Getting Started
- Configuration
- Security
- How to Contribute
- License
- Acknowledgements
- Author
About
Nathan writes, records, and films his own music. Publishing each video still takes metadata work: a title, a description, tags, hashtags, consistency with the rest of the channel, and a check that the audio isn't clipping or dropping out.
Soundboard produces one complete draft of that metadata for Nathan to review, instead of a set of options to choose from.
The…
⚖️ MIT. Gemma's weights ship separately under Apache 2.0.
The requirement: if Nathan fixes a title once, he shouldn't fix it the same way twice. For this build, I pass his saved edits into the next prompt instead of maintaining a fine-tuning pipeline or vector database.
A main-video approval writes an EDITED row per changed field plus an ACCEPTED row; a re-run writes SKIPPED. Short approvals record edits only, since their metadata starts from the video's draft. Feedback and the job's state change commit in one Firestore transaction, so the app can't remember an approval that never happened.
export function approvalFeedback(
proposed: PickFields,
final: PickFields,
meta: { jobId: string; songTitle: string; pickVersion: number; at: number },
): Feedback[] {
const rows: Feedback[] = [];
const asText = (v: string | string[]) => (Array.isArray(v) ? v.join(', ') : v);
for (const field of ['title', 'description', 'tags'] as const) {
const before = asText(proposed[field]);
const after = asText(final[field]);
if (before !== after) rows.push({ kind: 'EDITED', field, before, after, ...meta });
}
rows.push({ kind: 'ACCEPTED', ...meta, after: final.title });
return rows;
}
export function weighFeedback(feedback: Feedback[]) {
return feedback.map((f) => ({
weight: f.kind === 'SKIPPED' ? 'weak' : 'strong',
kind: f.kind,
song: f.songTitle,
field: f.field,
before: f.before?.slice(0, 300),
after: f.after?.slice(0, 300),
}));
}
Edits and approvals outweigh a re-run, since "try again" doesn't say what he disliked. Gemma gets the before/after pairs so it can see what he actually changed. Nathan can change his mind. He's allowed; it's his music.
Demo runs read his memory but write to their own jobs. Letting strangers teach the app what Nathan likes would be a spectacularly stupid feature.
How I Built It
I'm running Gemma 4 12B-it through llama.cpp's llama-server, with Google's open weights under Apache 2.0. The three “agents” are TypeScript prompt builders calling the same /v1/chat/completions endpoint with strict JSON schemas, which didn't need an agent framework. Gemma's 30-second audio limit sets the window size at 29.5 seconds, and the page advances one step per request because a 12B model on one L4 takes a few seconds to answer. I can work with that; pretending it's instant doesn't make it faster.
| Piece | Choice |
|---|---|
| Model | Gemma 4 12B-it, Google's Q4_0 GGUF + vision/audio projector, on llama-server
|
| Hosting | One Cloud Run app instance + one Vertex AI model endpoint with one NVIDIA L4; both scale to zero |
| Media | ffmpeg: 29.5 s windows (under Gemma's 30 s audio cap), 16 kHz WAV + 8 frames + loudness/peak/clipping/silence |
| Agents |
chunk-analyst analyzes each window; smart-pick writes one draft for YouTube + Bandcamp; a separate brand-guide call proposes keep/fix/drop rules from his channel |
| Short | Gemma picks the hook window and length; ffmpeg's loudness places the cut start and renders 1080×1920; no generated frames |
| Upload | Private upload, then videos.list read-back before it's marked verified—video and Short both |
| Stack | SvelteKit 2, Svelte 5, TypeScript, Node 24, Firestore, Cloud Storage, Secret Manager, YouTube Data API v3, Sentry |
The page runs one pipeline step per request. A scaled-down, loading, or busy model returns a wait reason; reloading resumes the job.
Why Does Open Innovation Matter?
Nathan's unreleased music is the input. Gemma runs on a Vertex AI endpoint in my GCP project, so I don't send that audio to a separate inference API. Storage and uploads still touch GCP and YouTube, and Sentry gets traces with the media stripped, but I decide where the model runs and what reaches it.
Open weights also leave the door open to fine-tune on his music and audience data later, without replacing what I built this weekend.
Prize Categories
Best Use of Gemma 🪙
Gemma gets the audio and frames, not a description of them. chunk-analyst reports genre terms, instrumentation, vocal type, mood, and tempo feel in words. No lyrics.
Those terms drive a YouTube search of the genre's top videos. Hashtags and tags are picked from the job's candidate lists, with the artist name included in the tag pool, and tags must name something the analysis heard. The description's structure follows the audience evidence; its wording follows ARTIST_VOICE, distilled from Nathan's own YouTube descriptions: short, literal, his "hacked and slashed" credit line, the dry aside about what genre this even is. The title, description, and tags return a why.
Calls use a strict JSON schema, temperature 0.2, and a 2048-token budget. A bad parse gets one retry if the step has time left.
Audio numbers come from ffmpeg. The model is told not to produce them, and the server strips any it does. I don't need Gemma's opinion on what the meter probably said.
export function measuredFlags(m: Measurements | null): string[] {
if (!m) return [];
const flags: string[] = [];
if (m.truePeakDbtp !== null && m.truePeakDbtp > -1) {
flags.push(`True peak ${m.truePeakDbtp.toFixed(1)} dBTP, above the -1 dBTP ceiling`);
}
if (m.clippedSamples > 0) flags.push(`${m.clippedSamples} samples at full scale (clipping)`);
for (const s of m.silences.filter((s) => s.end - s.start >= 2)) {
flags.push(`Silence ${s.start.toFixed(1)}s to ${s.end.toFixed(1)}s`);
}
return flags;
}
Sentry Agent Tracing 🩻
Nathan is a fantastic musician and terrible with technology, so I'm also the support team. I already know the bug report: "it's broken," some unspecified time after it broke.
One Sentry trace per job: agent calls, duration, tokens, messages. Each invoke_agent span wraps the model's chat span. Audio and images become {type, mime, bytes} placeholders first; his unreleased track doesn't belong in my error tracker.
Two things the traces caught before Nathan did. First, every invoke_agent span was empty: Sentry only shows an agent's input and output from its own span, and I'd put them on the chat span underneath. Second, smart-pick was spending about 4,200 of its 8,192-token context on the prompt alone—each of his five previous uploads cost a description excerpt and a thumbnail. Three uploads still carry his credit and contact lines. A live pick dropped to 3,119 prompt tokens.
GitHub Copilot 🪛
Every PR gets a Copilot review before I merge it. GitHub Actions runs format, lint, typecheck, 1,050+ unit tests with coverage, and Playwright E2E on code PRs. CodeQL runs on code PRs, code pushes to main, and a weekly schedule; release-please handles versioning and changelogs. For a one-person build that has to outlive the weekend, the bot reviewer and the pipeline are the team.
Now He Has to Use It 🎬
Social posts and analytics can come later. First Nathan has to ship a real release with it and tell me what still annoys him. If I've done my job, the YouTube description stops being the unfinished task next to his music.
He's already plotting out the next set of features for me to build. He also says this version of AI is a lot easier to manage than the way he was doing it before.
🛡️ Apparently I Also Need an Editor
Claude helped draft this post, and Codex helped remove the parts that sounded like Claude helped draft this post—including writing this footer. I'm responsible for the claims and the app; Nathan is responsible for texting me when it breaks.






Top comments (9)
Hi Ashley,
I read your Soundboard submission,
then went through the repo and README.
I'm not an engineer — just someone
with scientific curiosity who loves exploring.
That's exactly why a few things stayed with me:
Running Gemma on your own GPU
so unreleased audio never leaves your project.
A principled choice, not just a technical one.
Using ffmpeg to measure loudness
and clipping instead of letting the model guess.
That distinction between measuring and guessing
reflects deep thinking.
The honesty about cost and
about what the tool can't promise.
That kind of transparency is rare.
It's a great example of what
"Build for a Friend" should look like.
Starred the repo as a small
token of appreciation. Thanks for
sharing this with the community.
Keep building 🙏😁
Thank you so much! There's a ton of things to build out here, starting with analytics. So I imagine this project will keep me busy for a while 😆
Thanks for sharing your work 🙌 Excited to see the analytics side 🙂 Keep it up!
Wow, building a whole marketing workflow for your musician friend in one weekend is impressive! The idea of auto‑generating short clip hooks from source video with ffmpeg is super clever, I want to try this demo out.
Thanks! You can sign in with username
anchildress1demo@gmail.comand passwordxAf(nH*2Qf1T. It's deployed at soundboard.anchildress1.dev.What a GREAT idea. LOVE this. Well done. 🤩
Thank you!
Nice !! Did you get sticker in hacktoberfest for this challenge .
You know... I don't remember 😅😆 Thanks, though! I'll have to go look.