Created
February 14, 2026 17:47
-
-
Save hzoo/af29e0b4b20c2345c68475a8340a0b56 to your computer and use it in GitHub Desktop.
Archived agent-sounds preset fetch scripts (SC2, Nintendo, OG packs) from commit 9d7ebd9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { existsSync } from "node:fs"; | |
| import { copyFile, mkdir, unlink, writeFile } from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | |
| const BASE = "https://downloads.khinsider.com"; | |
| const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"; | |
| const DELAY_MS = 1000; | |
| const ALBUMS: Record<string, [string, number]> = { | |
| switch: ["nintendo-switch-sound-effects", 5], | |
| gamecube: ["nintendo-gamecube-system-soundtrack-gc-gamerip-2001", 10], | |
| gba: ["gameboy-advance-system-music", 5], | |
| ds: ["nintendo-ds-system-music", 15], | |
| wii: ["wii-system-soundtrack", 15], | |
| }; | |
| const SKIP_TRACKS = new Set(["ds::Signal Search"]); | |
| const HOOK_MAP: Record<string, string[]> = { | |
| "switch::Turn On": ["session-start"], | |
| "switch::Klick": ["prompt-submit"], | |
| "switch::Select": ["prompt-submit"], | |
| "switch::This One": ["prompt-submit"], | |
| "switch::Tick": ["prompt-submit"], | |
| "switch::Turn Off": ["session-end"], | |
| "switch::Standby": ["stop"], | |
| "switch::Loading": ["pre-compact"], | |
| "switch::Eshop Intro": ["subagent-start"], | |
| "switch::Dada 0": ["subagent-start"], | |
| "switch::Dada 1": ["subagent-start"], | |
| "switch::Dada 2": ["subagent-start"], | |
| "switch::Dada 3": ["subagent-start"], | |
| "switch::Bing": ["task-completed"], | |
| "switch::Dodo": ["subagent-stop"], | |
| "switch::Jig 0": ["task-completed"], | |
| "switch::Jig 1": ["task-completed"], | |
| "switch::Error": ["tool-failure"], | |
| "switch::News": ["notification"], | |
| "switch::Home": ["notification"], | |
| "switch::Album": ["notification"], | |
| "switch::Icons": ["notification"], | |
| "switch::Nock": ["permission-request"], | |
| "switch::Enter & Back": ["permission-request"], | |
| "switch::Popup + Run Title": ["permission-request"], | |
| "switch::Controller": ["subagent-stop"], | |
| "switch::Settings": ["pre-compact"], | |
| "switch::User": ["notification"], | |
| "switch::Border": ["stop"], | |
| "switch::Eshop": ["subagent-stop"], | |
| "gamecube::Startup Sound (Standard)": ["session-start"], | |
| "gamecube::Startup Sound (Whimsical)": ["task-completed"], | |
| "gamecube::Startup Sound (Kabuki)": ["notification"], | |
| "gba::Game Boy Advance BIOS": ["session-start", "task-completed"], | |
| "gba::Game Boy BIOS": ["notification", "subagent-stop"], | |
| "gba::Gameboy Advance Link Cable": ["subagent-start"], | |
| "ds::Start up": ["session-start"], | |
| "ds::Start Up on your birthday": ["task-completed"], | |
| "ds::PictoChat Enter": ["subagent-start"], | |
| "ds::PictoChat Birthday Enter": ["notification"], | |
| "ds::Alarm": ["tool-failure"], | |
| "wii::Wii Menu Startup": ["session-start"], | |
| "wii::No Disc Inserted Banner": ["tool-failure"], | |
| "wii::Mii Channel Banner": ["notification"], | |
| "wii::Virtual Console Banner": ["task-completed"], | |
| "wii::SD Card Software Banner": ["pre-compact"], | |
| "wii::GameCube Disc Banner": ["subagent-stop"], | |
| "wii::Wii System v1.0 Channel Banner": ["subagent-start"], | |
| "wii::Wii Fit Channel Banner": ["permission-request"], | |
| "wii::Wii Fit Body Check Channel Banner": ["permission-request"], | |
| "wii::Mario Kart Channel Banner": ["subagent-start"], | |
| "wii::Wii + Internet Channel Banner": ["subagent-start"], | |
| "wii::Skyward Sword Save Data Update Channel Banner": ["notification"], | |
| "wii::Wii U Transfer Tool Channel Banner": ["notification"], | |
| "wii::Wii U Transfer Tool - Transfer Completed": ["task-completed"], | |
| }; | |
| const CURATED: Record<string, [string, string]> = { | |
| "session-start": ["gamecube", "Startup Sound (Standard)"], | |
| "prompt-submit": ["switch", "Klick"], | |
| stop: ["switch", "Turn Off"], | |
| "pre-compact": ["switch", "Loading"], | |
| "subagent-start": ["switch", "Eshop Intro"], | |
| "subagent-stop": ["gba", "Game Boy Advance BIOS"], | |
| "tool-failure": ["switch", "Error"], | |
| notification: ["switch", "News"], | |
| "task-completed": ["switch", "Bing"], | |
| "permission-request": ["switch", "Nock"], | |
| "session-end": ["wii", "Wii Menu Startup"], | |
| }; | |
| type Track = { name: string; url: string; duration: number }; | |
| function escapeRegExp(text: string): string { | |
| return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| function decodeHtmlEntities(text: string): string { | |
| const named: Record<string, string> = { | |
| amp: "&", | |
| lt: "<", | |
| gt: ">", | |
| quot: "\"", | |
| apos: "'", | |
| nbsp: " ", | |
| }; | |
| return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, token: string) => { | |
| if (token.startsWith("#x") || token.startsWith("#X")) { | |
| const code = Number.parseInt(token.slice(2), 16); | |
| return Number.isFinite(code) ? String.fromCodePoint(code) : match; | |
| } | |
| if (token.startsWith("#")) { | |
| const code = Number.parseInt(token.slice(1), 10); | |
| return Number.isFinite(code) ? String.fromCodePoint(code) : match; | |
| } | |
| return named[token] ?? match; | |
| }); | |
| } | |
| function usage(exitCode = 0): never { | |
| console.log("Usage: bun run tools/fetch-nintendo-sounds.ts [--dry-run] [--console switch|gamecube|gba|ds|wii|all]"); | |
| process.exit(exitCode); | |
| } | |
| function key(consoleName: string, trackName: string) { | |
| return `${consoleName}::${trackName}`; | |
| } | |
| function parseArgs(argv: string[]) { | |
| const consoles = Object.keys(ALBUMS); | |
| let dryRun = false; | |
| let consoleName: string | "all" = "all"; | |
| for (let i = 0; i < argv.length; i += 1) { | |
| const arg = argv[i]; | |
| if (arg === "--help" || arg === "-h") usage(0); | |
| if (arg === "--dry-run") { | |
| dryRun = true; | |
| continue; | |
| } | |
| if (arg === "--console") { | |
| const value = argv[i + 1]; | |
| if (!value) usage(1); | |
| if (value === "all" || consoles.includes(value)) { | |
| consoleName = value; | |
| i += 1; | |
| continue; | |
| } | |
| console.error(`unknown console: ${value}`); | |
| usage(1); | |
| } | |
| console.error(`unknown arg: ${arg}`); | |
| usage(1); | |
| } | |
| return { dryRun, consoles: consoleName === "all" ? consoles : [consoleName] }; | |
| } | |
| function sleep(ms: number) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function fetchText(url: string): Promise<string> { | |
| const resp = await fetch(url, { headers: { "User-Agent": UA } }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| return await resp.text(); | |
| } | |
| async function downloadBinary(url: string, dest: string) { | |
| const resp = await fetch(url, { headers: { "User-Agent": UA, Referer: BASE } }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| const bytes = new Uint8Array(await resp.arrayBuffer()); | |
| await writeFile(dest, bytes); | |
| return bytes.byteLength; | |
| } | |
| function parseAlbum(html: string, slug: string): Track[] { | |
| const tracks: Track[] = []; | |
| const seen = new Set<string>(); | |
| const linkRe = new RegExp(`<a\\s+href="(/game-soundtracks/album/${escapeRegExp(slug)}/[^"]+)"[^>]*>\\s*([^<]+?)\\s*</a>`); | |
| const durationRe = /(\d+):(\d{2})/; | |
| const rows = html.match(/<tr[^>]*>(.*?)<\/tr>/gs) ?? []; | |
| for (const row of rows) { | |
| const linkMatch = row.match(linkRe); | |
| if (!linkMatch) continue; | |
| const urlPath = linkMatch[1]; | |
| const name = decodeHtmlEntities(linkMatch[2].trim()); | |
| if (["MP3", "FLAC", "OGG"].includes(name.toUpperCase())) continue; | |
| if (seen.has(name)) continue; | |
| seen.add(name); | |
| const d = row.match(durationRe); | |
| const duration = d ? Number(d[1]) * 60 + Number(d[2]) : 999; | |
| tracks.push({ name, url: `${BASE}${urlPath}`, duration }); | |
| } | |
| return tracks; | |
| } | |
| function parseDownloadUrl(html: string): string | null { | |
| const audio = html.match(/<audio[^>]*>\s*<source\s+src="([^"]+)"/); | |
| if (audio?.[1]) return audio[1]; | |
| for (const pattern of [ | |
| /href="(https?:\/\/[^"\s]*(?:vgmsite|vgmtreasurechest)[^"\s]*\.mp3[^"\s]*)"/, | |
| /href="(https?:\/\/[^"\s]*\.mp3)"/, | |
| ]) { | |
| const match = html.match(pattern); | |
| if (match?.[1]) return match[1]; | |
| } | |
| return null; | |
| } | |
| function safeFilename(name: string): string { | |
| return `${name.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").toLowerCase()}.mp3`; | |
| } | |
| async function main() { | |
| const { dryRun, consoles } = parseArgs(process.argv.slice(2)); | |
| if (dryRun) console.log(" --dry-run: no files will be written"); | |
| const downloaded: Record<string, Record<string, string>> = {}; | |
| for (const consoleName of consoles) { | |
| const [slug, maxDuration] = ALBUMS[consoleName]; | |
| const presetDir = path.join(ROOT, "presets", `nintendo-${consoleName}`); | |
| if (!dryRun) await mkdir(presetDir, { recursive: true }); | |
| console.log(`\n [${consoleName.toUpperCase()}]`); | |
| console.log(` album: ${slug}`); | |
| let html: string; | |
| try { | |
| html = await fetchText(`${BASE}/game-soundtracks/album/${slug}`); | |
| } catch (error) { | |
| console.log(` FAILED to fetch album page: ${String(error)}`); | |
| continue; | |
| } | |
| const tracks = parseAlbum(html, slug); | |
| console.log(` ${tracks.length} tracks found`); | |
| const kept: Track[] = []; | |
| for (const track of tracks) { | |
| if (SKIP_TRACKS.has(key(consoleName, track.name))) { | |
| console.log(` skip: ${track.name} (excluded)`); | |
| continue; | |
| } | |
| if (track.duration > maxDuration) { | |
| console.log(` skip: ${track.name} (${track.duration}s > ${maxDuration}s)`); | |
| continue; | |
| } | |
| kept.push(track); | |
| console.log(` keep: ${track.name} (${track.duration}s)`); | |
| } | |
| if (dryRun) { | |
| console.log(" --dry-run, preview only"); | |
| downloaded[consoleName] = downloaded[consoleName] ?? {}; | |
| for (const track of kept) { | |
| downloaded[consoleName][track.name] = safeFilename(track.name); | |
| } | |
| continue; | |
| } | |
| let failed = 0; | |
| for (const track of kept) { | |
| const filename = safeFilename(track.name); | |
| const dest = path.join(presetDir, filename); | |
| downloaded[consoleName] = downloaded[consoleName] ?? {}; | |
| if (existsSync(dest)) { | |
| console.log(` exists: ${filename}`); | |
| downloaded[consoleName][track.name] = filename; | |
| continue; | |
| } | |
| process.stdout.write(` ${track.name}... `); | |
| await sleep(DELAY_MS); | |
| try { | |
| const trackHtml = await fetchText(track.url); | |
| const dlUrl = parseDownloadUrl(trackHtml); | |
| if (!dlUrl) { | |
| console.log("FAILED (no download URL in page)"); | |
| failed += 1; | |
| continue; | |
| } | |
| await sleep(DELAY_MS); | |
| const size = await downloadBinary(dlUrl, dest); | |
| downloaded[consoleName][track.name] = filename; | |
| console.log(`OK (${Math.floor(size / 1024)}KB)`); | |
| } catch (error) { | |
| console.log(`FAILED: ${String(error)}`); | |
| failed += 1; | |
| if (existsSync(dest)) await unlink(dest); | |
| } | |
| } | |
| if (failed > 0) { | |
| console.log(` ${failed} downloads failed`); | |
| } | |
| const preset: { name: string; sounds: Record<string, Array<{ file: string; unit: string; quote: string }>> } = { | |
| name: `Nintendo ${consoleName.toUpperCase()}`, | |
| sounds: {}, | |
| }; | |
| for (const [trackName, filename] of Object.entries(downloaded[consoleName] ?? {})) { | |
| const hooks = HOOK_MAP[key(consoleName, trackName)] ?? []; | |
| for (const hook of hooks) { | |
| (preset.sounds[hook] ||= []).push({ | |
| file: filename, | |
| unit: consoleName, | |
| quote: trackName, | |
| }); | |
| } | |
| } | |
| const manifest = path.join(presetDir, "preset.json"); | |
| await writeFile(manifest, `${JSON.stringify(preset, null, 2)}\n`, "utf8"); | |
| console.log(` wrote ${manifest}`); | |
| } | |
| if (dryRun) { | |
| console.log("\n [CURATED PREVIEW]"); | |
| for (const [hook, [consoleName, trackName]] of Object.entries(CURATED)) { | |
| const filename = downloaded[consoleName]?.[trackName]; | |
| if (!filename) { | |
| console.log(` missing: ${consoleName}/${trackName} -> ${hook}`); | |
| } else { | |
| console.log(` ${hook}: ${consoleName}/${trackName} (${filename})`); | |
| } | |
| } | |
| return; | |
| } | |
| console.log("\n [CURATED]"); | |
| const curatedDir = path.join(ROOT, "presets", "nintendo"); | |
| await mkdir(curatedDir, { recursive: true }); | |
| const preset: { name: string; sounds: Record<string, Array<{ file: string; unit: string; quote: string }>> } = { | |
| name: "Nintendo", | |
| sounds: {}, | |
| }; | |
| for (const [hook, [consoleName, trackName]] of Object.entries(CURATED)) { | |
| const filename = downloaded[consoleName]?.[trackName]; | |
| if (!filename) { | |
| console.log(` missing: ${consoleName}/${trackName} → ${hook}`); | |
| continue; | |
| } | |
| const src = path.join(ROOT, "presets", `nintendo-${consoleName}`, filename); | |
| const dstName = `${consoleName}-${filename}`; | |
| const dst = path.join(curatedDir, dstName); | |
| if (existsSync(src) && !existsSync(dst)) { | |
| await copyFile(src, dst); | |
| } | |
| preset.sounds[hook] = [{ | |
| file: dstName, | |
| unit: consoleName, | |
| quote: trackName, | |
| }]; | |
| console.log(` ${hook}: ${consoleName}/${trackName}`); | |
| } | |
| const manifest = path.join(curatedDir, "preset.json"); | |
| await writeFile(manifest, `${JSON.stringify(preset, null, 2)}\n`, "utf8"); | |
| console.log(` wrote ${manifest}`); | |
| console.log("\n activate with: agent-sounds use nintendo"); | |
| } | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { existsSync } from "node:fs"; | |
| import { mkdir, unlink, writeFile } from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | |
| const RAW = "https://raw.githubusercontent.com/PeonPing/og-packs/main"; | |
| const PACKS = ["glados", "ocarina_of_time"] as const; | |
| const CATEGORY_MAP: Record<string, string[]> = { | |
| "session.start": ["session-start"], | |
| "task.acknowledge": ["prompt-submit"], | |
| "task.complete": ["stop", "task-completed"], | |
| "task.error": ["tool-failure"], | |
| "input.required": ["permission-request"], | |
| "resource.limit": ["pre-compact"], | |
| }; | |
| type Pack = (typeof PACKS)[number]; | |
| type OpenPeonSound = { | |
| file: string; | |
| label?: string; | |
| }; | |
| type OpenPeonCategory = { | |
| sounds?: OpenPeonSound[]; | |
| }; | |
| type OpenPeonManifest = { | |
| display_name?: string; | |
| categories?: Record<string, OpenPeonCategory>; | |
| }; | |
| function usage(exitCode = 0): never { | |
| console.log("Usage: bun run tools/fetch-og-packs.ts [--dry-run] [--pack glados|ocarina_of_time|all]"); | |
| process.exit(exitCode); | |
| } | |
| function parseArgs(argv: string[]) { | |
| let dryRun = false; | |
| let pack: Pack | "all" = "all"; | |
| for (let i = 0; i < argv.length; i += 1) { | |
| const arg = argv[i]; | |
| if (arg === "--help" || arg === "-h") usage(0); | |
| if (arg === "--dry-run") { | |
| dryRun = true; | |
| continue; | |
| } | |
| if (arg === "--pack") { | |
| const value = argv[i + 1]; | |
| if (!value) usage(1); | |
| if (value === "all" || PACKS.includes(value as Pack)) { | |
| pack = value as Pack | "all"; | |
| i += 1; | |
| continue; | |
| } | |
| console.error(`unknown pack: ${value}`); | |
| usage(1); | |
| } | |
| console.error(`unknown arg: ${arg}`); | |
| usage(1); | |
| } | |
| return { dryRun, packs: pack === "all" ? [...PACKS] : [pack] }; | |
| } | |
| async function fetchJson<T>(url: string): Promise<T> { | |
| const resp = await fetch(url); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| return (await resp.json()) as T; | |
| } | |
| async function download(url: string, dest: string): Promise<number> { | |
| const resp = await fetch(url); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| const bytes = new Uint8Array(await resp.arrayBuffer()); | |
| await writeFile(dest, bytes); | |
| return bytes.byteLength; | |
| } | |
| async function main() { | |
| const { dryRun, packs } = parseArgs(process.argv.slice(2)); | |
| if (dryRun) { | |
| console.log(" --dry-run: no files will be written"); | |
| } | |
| for (const pack of packs) { | |
| console.log(`\n [${pack}]`); | |
| const manifestUrl = `${RAW}/${pack}/openpeon.json`; | |
| const meta = await fetchJson<OpenPeonManifest>(manifestUrl); | |
| const display = meta.display_name ?? pack; | |
| console.log(` ${display}`); | |
| const presetDir = path.join(ROOT, "presets", pack, "sounds"); | |
| if (!dryRun) { | |
| await mkdir(presetDir, { recursive: true }); | |
| } | |
| const preset: { name: string; sounds: Record<string, Array<{ file: string; unit: string; quote: string }>> } = { | |
| name: display, | |
| sounds: {}, | |
| }; | |
| for (const [category, categoryData] of Object.entries(meta.categories ?? {})) { | |
| const events = CATEGORY_MAP[category]; | |
| if (!events) { | |
| console.log(` skip category: ${category}`); | |
| continue; | |
| } | |
| for (const sound of categoryData.sounds ?? []) { | |
| const srcFile = sound.file; | |
| const filename = srcFile.split("/").pop() ?? srcFile; | |
| const label = sound.label ?? filename; | |
| const dest = path.join(presetDir, filename); | |
| if (!existsSync(dest) && !dryRun) { | |
| const dlUrl = `${RAW}/${pack}/${srcFile}`; | |
| process.stdout.write(` ${filename}... `); | |
| try { | |
| const size = await download(dlUrl, dest); | |
| console.log(`OK (${Math.floor(size / 1024)}KB)`); | |
| } catch (error) { | |
| console.log(`FAILED: ${String(error)}`); | |
| if (existsSync(dest)) { | |
| await unlink(dest); | |
| } | |
| continue; | |
| } | |
| } else { | |
| console.log(` ${dryRun ? "[dry] " : ""}${filename}`); | |
| } | |
| for (const event of events) { | |
| (preset.sounds[event] ||= []).push({ | |
| file: `sounds/${filename}`, | |
| unit: pack, | |
| quote: label, | |
| }); | |
| } | |
| } | |
| } | |
| if (dryRun) { | |
| const total = Object.values(preset.sounds).reduce((acc, entries) => acc + entries.length, 0); | |
| console.log(` would map ${total} sounds into ${Object.keys(preset.sounds).length} events`); | |
| continue; | |
| } | |
| const manifestPath = path.join(ROOT, "presets", pack, "preset.json"); | |
| await writeFile(manifestPath, `${JSON.stringify(preset, null, 2)}\n`, "utf8"); | |
| console.log(` wrote ${manifestPath}`); | |
| } | |
| console.log(`\n activate with: agent-sounds use ${packs[0]}`); | |
| } | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { existsSync } from "node:fs"; | |
| import { mkdir, unlink, writeFile } from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | |
| const PRESET_DIR = path.join(ROOT, "presets", "starcraft2-full"); | |
| const UNITS: Record<string, string[]> = { | |
| protoss: [ | |
| "Adept", "Archon", "Carrier", "Colossus", "DarkTemplar", | |
| "Disruptor", "HighTemplar", "Immortal", "Mothership", | |
| "Observer", "Oracle", "Phoenix", "Probe", "Sentry", | |
| "Stalker", "Tempest", "VoidRay", "WarpPrism", "Zealot", | |
| "Arbiter", "Dragoon", | |
| ], | |
| terran: [ | |
| "SCV", "Marine", "Marauder", "Reaper", "Hellion", | |
| "SiegeTank", "Cyclone", "WidowMine", "Thor", "Viking", | |
| "Medivac", "Banshee", "Raven", "Battlecruiser", "Ghost", | |
| ], | |
| zerg: [ | |
| "Drone", "Baneling", "Hydralisk", "Mutalisk", "Corruptor", | |
| "BroodLord", "Infestor", "Lurker", | |
| ], | |
| }; | |
| const ACTION_HOOKS: Record<string, string[]> = { | |
| Ready: ["session-start", "subagent-start", "task-completed"], | |
| What: ["notification", "permission-request"], | |
| Yes: ["prompt-submit", "subagent-stop"], | |
| Attack: ["stop"], | |
| Help: ["tool-failure", "pre-compact"], | |
| Death: ["session-end"], | |
| }; | |
| const API = "https://starcraft.fandom.com/api.php"; | |
| type WikiImage = { name: string; url: string }; | |
| type AllImagesResponse = { | |
| query: { allimages: WikiImage[] }; | |
| continue?: { aicontinue?: string }; | |
| }; | |
| type SoundEntry = { | |
| unit: string; | |
| race: string; | |
| wiki_name: string; | |
| url: string; | |
| }; | |
| function usage(exitCode = 0): never { | |
| console.log("Usage: bun run tools/fetch-sc2-sounds.ts [--dry-run] [--race protoss|terran|zerg|all]"); | |
| process.exit(exitCode); | |
| } | |
| function parseArgs(argv: string[]) { | |
| const races = Object.keys(UNITS); | |
| let dryRun = false; | |
| let race: string | "all" = "all"; | |
| for (let i = 0; i < argv.length; i += 1) { | |
| const arg = argv[i]; | |
| if (arg === "--help" || arg === "-h") usage(0); | |
| if (arg === "--dry-run") { | |
| dryRun = true; | |
| continue; | |
| } | |
| if (arg === "--race") { | |
| const value = argv[i + 1]; | |
| if (!value) usage(1); | |
| if (value === "all" || races.includes(value)) { | |
| race = value; | |
| i += 1; | |
| continue; | |
| } | |
| console.error(`unknown race: ${value}`); | |
| usage(1); | |
| } | |
| console.error(`unknown arg: ${arg}`); | |
| usage(1); | |
| } | |
| return { dryRun, races: race === "all" ? races : [race] }; | |
| } | |
| async function fetchJson<T>(url: string): Promise<T> { | |
| const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| return (await resp.json()) as T; | |
| } | |
| async function apiListOgg(unit: string): Promise<WikiImage[]> { | |
| const files: WikiImage[] = []; | |
| const params = new URLSearchParams({ | |
| action: "query", | |
| list: "allimages", | |
| aiprefix: `${unit}_`, | |
| ailimit: "500", | |
| format: "json", | |
| }); | |
| while (true) { | |
| const url = `${API}?${params.toString()}`; | |
| const data = await fetchJson<AllImagesResponse>(url); | |
| for (const img of data.query.allimages) { | |
| if (img.name.endsWith(".ogg")) files.push(img); | |
| } | |
| const next = data.continue?.aicontinue; | |
| if (!next) break; | |
| params.set("aicontinue", next); | |
| } | |
| return files; | |
| } | |
| function parseAction(filename: string): string | null { | |
| const stem = filename.replace(/\.[^.]+$/, ""); | |
| const parts = stem.split("_", 2); | |
| if (parts.length < 2) return null; | |
| const action = parts[1].replace(/[0-9]+$/, ""); | |
| return ACTION_HOOKS[action] ? action : null; | |
| } | |
| async function download(url: string, dest: string) { | |
| const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); | |
| const bytes = new Uint8Array(await resp.arrayBuffer()); | |
| await writeFile(dest, bytes); | |
| } | |
| async function main() { | |
| const { dryRun, races } = parseArgs(process.argv.slice(2)); | |
| if (!dryRun) { | |
| await mkdir(PRESET_DIR, { recursive: true }); | |
| } | |
| const byAction: Record<string, SoundEntry[]> = {}; | |
| let total = 0; | |
| for (const race of races) { | |
| console.log(`\n [${race.toUpperCase()}]`); | |
| for (const unit of UNITS[race]) { | |
| process.stdout.write(` ${unit}... `); | |
| const files = await apiListOgg(unit); | |
| const oggs = files | |
| .map((file) => ({ file, action: parseAction(file.name) })) | |
| .filter((entry): entry is { file: WikiImage; action: string } => Boolean(entry.action)); | |
| console.log(`${oggs.length} sounds`); | |
| for (const { file, action } of oggs) { | |
| (byAction[action] ||= []).push({ | |
| unit, | |
| race, | |
| wiki_name: file.name, | |
| url: file.url, | |
| }); | |
| total += 1; | |
| } | |
| } | |
| } | |
| console.log(`\n total: ${total} sounds across ${Object.keys(byAction).length} actions`); | |
| for (const action of Object.keys(byAction).sort()) { | |
| const sounds = byAction[action] ?? []; | |
| const hooks = ACTION_HOOKS[action] ?? []; | |
| const counts: Record<string, number> = {}; | |
| for (const sound of sounds) { | |
| counts[sound.race] = (counts[sound.race] ?? 0) + 1; | |
| } | |
| const breakdown = Object.keys(counts) | |
| .sort() | |
| .map((race) => `${race}:${counts[race]}`) | |
| .join(", "); | |
| console.log(` ${action}: ${sounds.length} (${breakdown}) → ${hooks.join(", ")}`); | |
| } | |
| if (dryRun) { | |
| console.log("\n --dry-run, stopping here"); | |
| return; | |
| } | |
| console.log(`\n downloading to ${PRESET_DIR}...`); | |
| let failed = 0; | |
| for (const sounds of Object.values(byAction)) { | |
| for (const entry of sounds) { | |
| const dest = path.join(PRESET_DIR, entry.wiki_name); | |
| if (existsSync(dest)) continue; | |
| console.log(` ${entry.wiki_name}`); | |
| try { | |
| await download(entry.url, dest); | |
| } catch (error) { | |
| console.log(` FAILED: ${String(error)}`); | |
| failed += 1; | |
| if (existsSync(dest)) await unlink(dest); | |
| } | |
| } | |
| } | |
| const preset: { | |
| name: string; | |
| sounds: Record<string, Array<{ file: string; unit: string; race: string; quote: string }>>; | |
| } = { | |
| name: "StarCraft 2 (Full)", | |
| sounds: {}, | |
| }; | |
| for (const [action, hooks] of Object.entries(ACTION_HOOKS)) { | |
| const sounds = byAction[action] ?? []; | |
| for (const hook of hooks) { | |
| const entries = sounds | |
| .filter((sound) => existsSync(path.join(PRESET_DIR, sound.wiki_name))) | |
| .map((sound) => ({ | |
| file: sound.wiki_name, | |
| unit: sound.unit, | |
| race: sound.race, | |
| quote: `${sound.unit} ${action}`, | |
| })); | |
| if (entries.length > 0) { | |
| preset.sounds[hook] = entries; | |
| } | |
| } | |
| } | |
| const manifest = path.join(PRESET_DIR, "preset.json"); | |
| await writeFile(manifest, `${JSON.stringify(preset, null, 2)}\n`, "utf8"); | |
| console.log(`\n wrote ${manifest}`); | |
| if (failed > 0) console.log(` ${failed} downloads failed`); | |
| console.log(" activate with: agent-sounds use starcraft2-full"); | |
| } | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment