-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsoundmon.py
More file actions
1567 lines (1430 loc) · 81.8 KB
/
Copy pathsoundmon.py
File metadata and controls
1567 lines (1430 loc) · 81.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""soundmon — make a game-ready sound effect from a text description.
This is the brains; run it through the `soundmon` wrapper, which makes sure the
ComfyUI server is running first. It talks to ComfyUI's HTTP API (so the visual
node-graph happens behind the scenes — you just describe the sound).
Sibling project to pixelmon; same architecture, one dimension over.
"""
import argparse
import json
import os
import random
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
# soundmon can render on a REMOTE ComfyUI (e.g. a faster box on the LAN). Choose a
# target with `--server NAME` (an alias from servers.json) or `--server host[:port]`/URL,
# or the SOUNDMON_SERVER env var. When the target is remote, results are fetched back
# over HTTP (/view) — no shared filesystem needed.
#
# THE DEFAULT IS THE `local` ALIAS, NOT A HARDCODED 127.0.0.1. That literal was the
# real default before, so repointing the alias in servers.json changed `--server local`
# and left a bare `soundmon "..."` still aimed at a machine with no ComfyUI on it —
# two defaults that could disagree, which is exactly the kind of split that makes a
# config change look like it did nothing. Now there is one place to edit.
POOL = [] # >1 entry (--server a,b,c) turns on render-farm mode (jobs fan across GPUs)
COMFY = os.path.expanduser("~/ComfyUI")
OUTPUT = os.path.join(COMFY, "output")
SFX_DIR = os.path.join(COMFY, "custom_nodes", "retro_sfx")
# Pull the format names straight from the node's registry so the two never drift
# apart (and so --list-formats reflects formats you add yourself).
sys.path.insert(0, SFX_DIR)
try:
import nodes as _sfx
FORMATS = list(_sfx.FORMATS.keys())
except Exception:
_sfx = None
FORMATS = ["none", "amiga", "sb", "sb22", "gameboy", "nes", "snes", "psx", "cd"]
# Style guides (prompt snippets) loaded from sounds.json next to this script.
_SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
try:
with open(os.path.join(_SCRIPT_DIR, "sounds.json"), encoding="utf-8") as _sf:
STYLES = {k: v for k, v in json.load(_sf).items() if not k.startswith("_")}
except Exception:
STYLES = {}
# Named ComfyUI targets for `--server NAME`. Your personal servers.json (gitignored)
# is loaded if present; otherwise just the built-in 'local'.
try:
with open(os.path.join(_SCRIPT_DIR, "servers.json"), encoding="utf-8") as _svf:
SERVERS = {k: v for k, v in json.load(_svf).items() if not k.startswith("_")}
except Exception:
SERVERS = {}
SERVERS.setdefault("local", "http://127.0.0.1:8188")
SERVER = SERVERS.get("local", "http://127.0.0.1:8188")
REMOTE = not any(_h in SERVER for _h in ("127.0.0.1", "localhost", "[::1]"))
# Stable Audio 3's prompt-rewriter system prompts, lifted verbatim from
# ComfyUI's official SA3 workflow (JsonExtractString node). SA3 is trained on
# richly structured prompts — named instrumentation, arrangement, and a
# "BPM: X. Length: Y seconds" tail — and these turn a loose description into
# that shape. Skipping this stage yields technically clean but musically weak
# results; it is part of the pipeline, not a nicety.
try:
with open(os.path.join(_SCRIPT_DIR, "sa3_reprompt.json"), encoding="utf-8") as _rf:
SA3_REPROMPT = json.load(_rf)
except Exception:
SA3_REPROMPT = {}
# Default negatives. The SFX one pushes *music and speech* away, because an SFX
# request drifts into a little musical phrase surprisingly often — but that same
# negative sabotages --music, which is why the two are separate. Either can be
# overridden with an explicit --negative.
SFX_NEGATIVE = ("music, melody, song, speech, voice, vocals, "
"low quality, distorted, clipping, hiss, background noise")
MUSIC_NEGATIVE = ("sound effect, foley, speech, spoken word, silence, "
"low quality, distorted, clipping, hiss, muffled")
# What --song pushes away. ACE-Step takes genre TAGS rather than a sentence, so
# the negative is a tag list too.
SONG_NEGATIVE = "low quality, noisy, distorted, clipping, muffled, off-key, amateur"
# Musical keys accepted by --key, mirroring ComfyUI's TextEncodeAceStepAudio1.5
# combo exactly (17 roots x 2 qualities). Order matters only for --list-keys.
KEYS = [f"{root} {quality}"
for quality in ("major", "minor")
for root in ("C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb",
"G", "G#", "Ab", "A", "A#", "Bb", "B")]
def resolve_server(value):
"""Resolve a --server value (a servers.json alias, or host[:port]/full URL) to a URL."""
import urllib.parse
url = SERVERS.get(value, value)
if "://" not in url:
url = "http://" + url
parsed = urllib.parse.urlparse(url)
if not parsed.port:
url = f"{parsed.scheme}://{parsed.hostname}:8188"
return url.rstrip("/")
def _colors():
"""ANSI color codes — auto-disabled when piped or NO_COLOR is set."""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
return {k: "" for k in ("b", "dim", "cyan", "grn", "yel", "mag", "rst")}
return {"b": "\033[1m", "dim": "\033[2m", "cyan": "\033[36m", "grn": "\033[32m",
"yel": "\033[33m", "mag": "\033[35m", "rst": "\033[0m"}
C = _colors()
def print_help():
c = C
def opt(flag, desc, default=""):
tail = f" {c['dim']}[{default}]{c['rst']}" if default else ""
return f" {c['grn']}{flag:<19}{c['rst']} {desc}{tail}"
def ex(cmd, note):
return f" {c['yel']}{cmd:<48}{c['rst']}{c['dim']}{note}{c['rst']}"
print("\n".join([
f"{c['b']}{c['mag']}soundmon{c['rst']} — generate sound effects from a text description",
"",
f"{c['b']}{c['cyan']}USAGE{c['rst']}",
f" {c['mag']}soundmon{c['rst']} {c['yel']}\"a description\"{c['rst']} [options]",
"",
f"{c['b']}{c['cyan']}EXAMPLES{c['rst']}",
ex('soundmon "a heavy wooden door creaking open"', "best quality (the default)"),
ex('soundmon "a sword hitting metal" --style impact', "steer with a style guide"),
ex('soundmon "footsteps on gravel" -n 4', "4 variations to pick from"),
ex('soundmon "a lo-fi hip hop piano loop" --music', "musical loops/beds/stings"),
ex('soundmon "laser blast" --format sb --bits 8', "SoundBlaster-era 8-bit crunch"),
ex('soundmon --batch "door,glass,fire" -n 8', "8 of each → own folders"),
ex('soundmon "rain" --seconds 30 --server rtx,titan', "long, fanned across GPUs"),
ex("soundmon --narrate-file lines.txt --ogg", "speak a script with a TTS voice"),
ex("soundmon --record-file lines.txt --ogg", "record that same script yourself"),
"",
f"{c['b']}{c['cyan']}OPTIONS{c['rst']}",
opt("description", "the sound you want (in quotes)"),
opt("-n, --number N", "how many to make, each a different seed", "1"),
opt('--batch "a,b,c"', "round-robin subjects → a folder each (N of each)"),
opt("--seconds N", "length in seconds (model max 47)", "10"),
opt("--style NAMES", "append proven style guide(s) — see --list-styles"),
opt("--music", "music mode: loops/beds/stings (no full songs or vocals)"),
opt("--format NAME", "hardware format lock — see --list-formats", "none"),
opt("--bits N", "WAV bit depth: 8 / 16 / 24", "16"),
opt("--seed N", "lock / repeat a result (re-run a favorite)", "random"),
opt("--steps N", "sampling steps (more = better, slower)", "50"),
opt("--cfg N", "prompt adherence (higher = stricter)", "5.0"),
opt("--fast", "16 steps: ~3x faster, rougher"),
opt("--no-trim", "keep the model's leading/trailing silence"),
opt("--loop", "crossfade tail over head so the track loops seamlessly"),
opt("--chip", "real 2A03 chiptune synthesis — no model, loops perfectly"),
opt("--mood NAME", "heroic/ominous/eerie/... or auto — see --list-moods", "auto"),
opt("--arp N", "arpeggio speed in 16ths (0 = let the mood choose)", "0"),
opt("--opl", "real AdLib/OPL3 FM via the Nuked core — no model"),
opt("--opl-bank F", "external patch bank (.sbi); omit for built-in"),
opt("--chipfx", "synthesize an 8-bit SFX (PSG) — sfxr-style, no model"),
opt("--oplfx", "synthesize an SFX through the OPL3 FM core"),
opt("--fx-archetype N", "force the archetype (hit/boom/coin/creak/...)"),
opt("--blip", "JRPG text-box narration (Undertale / Animal Crossing)"),
opt("--blip-style S", "synth = no model at all | voice = Animalese", "synth"),
opt("--blip-wave W", "square / triangle / sine / saw / noise", "square"),
opt("--blip-rate N", "characters per second (typing speed)", "14"),
opt("--loop-crossfade SEC", "crossfade length for --loop", "2.0"),
opt("--normalize-db N", "peak level after generation", "-1.0"),
opt("--fade-ms N", "de-click fade on both ends", "5"),
opt("--list-formats", "show every hardware format"),
opt("--list-styles", "show every style guide"),
opt("-h, --help", "show this help"),
"",
f"{c['b']}{c['cyan']}ADVANCED{c['rst']}",
opt("--server NAMES", "remote ComfyUI (alias/host/URL); comma-list = render farm", "local"),
opt("--lufs N", "loudness CEILING in LUFS, attenuate-only ('off')", "-16"),
opt("--true-peak N", "true-peak ceiling in dBTP", "-1.0"),
opt("--ogg", "compress to OGG Vorbis (~25x smaller) — off by default"),
opt("--ogg-quality N", "OGG quality 0-10 (accuracy, not bandwidth)", "8"),
opt("--flac / --mp3 / --opus", "save compressed instead of WAV"),
opt('--negative "..."', "negative prompt (what to avoid)"),
opt("--name NAME", "output filename base", "from description"),
opt("--sampler NAME", "ksampler sampler", "dpmpp_3m_sde_gpu"),
opt("--scheduler NAME", "ksampler scheduler", "exponential"),
opt("--threshold-db N", "silence floor for trimming", "-45"),
opt("--engine NAME", "sa3 (full-band, fast) / sao (2024, 16kHz cut)", "sa3"),
opt("--sa3-size N", "small (specialist) / medium (generalist)", "small"),
opt("--base FILE", "checkpoint override", "per --engine"),
opt("--text-encoder FILE", "T5 text encoder", "t5_base"),
opt("--no-open", "don't auto-play the result"),
opt("--output-to DIR", "move outputs into DIR (relative to cwd)"),
opt("--move-to-dirs", "put a run in its own ./<description>/ folder"),
opt("--create-dirs", "create output folders if missing"),
opt("--no-subdirs", "with --batch/--output-to: dump all into one flat folder"),
"",
f"{c['b']}{c['cyan']}SONG{c['rst']} {c['dim']}(--song: full songs with real vocals, via ACE-Step 1.5. "
f"needs ./download-models.sh --song){c['rst']}",
opt("--song", "full-song mode; the description becomes genre TAGS"),
opt('--lyrics "..."', "lyrics to sing — use [verse] / [chorus] markers"),
opt("--lyrics-file F", "read lyrics from a file"),
opt("--bpm N", "tempo, 10-300", "120"),
opt('--key "A minor"', "musical key — see --list-keys", "C minor"),
opt("--timesig N", "time signature: 2 / 3 / 4 / 6", "4"),
opt("--lang CODE", "lyrics language (51 supported)", "en"),
opt("--no-audio-codes", "skip the quality LLM pass — much faster"),
opt("--llm-cfg N", "ACE text-encoder guidance", "2.0"),
opt("--temperature N", "LLM temperature", "0.85"),
opt("--list-keys", "show every musical key"),
"",
f"{c['b']}{c['cyan']}VOICE{c['rst']} {c['dim']}(spoken narration — two ways to make the same pack: "
f"a TTS voice, or your own){c['rst']}",
opt("--narrate", "speak the text with a TTS voice (Kokoro, local CPU)"),
opt("--record", "record it YOURSELF from a mic, line by line"),
opt("--narrate-file F", "narrate every 'key | text' row of a file"),
opt("--record-file F", "record every row instead — resumable, one take each"),
opt("--voice NAME", "TTS voice — see --list-voices", "bm_george"),
opt("--pitch N", "semitones, NEGATIVE = deeper (duration kept)", "0"),
opt("--speed N", "speech rate", "1.0"),
opt("--device NAME", "microphone to record from — see --list-devices", "default"),
opt("--record-rate N", "capture sample rate", "48000"),
opt("--list-voices", "show every TTS voice"),
opt("--list-devices", "show every microphone this machine can see"),
"",
f"{c['b']}{c['cyan']}OUTPUT{c['rst']}",
f" {c['dim']}{OUTPUT}/soundmon/{c['rst']}",
f" {c['dim']}44.1 kHz stereo WAV, trimmed and normalized{c['rst']}",
"",
f" {c['b']}{c['yel']}TIP{c['rst']} generation is cheap — run {c['grn']}-n 8{c['rst']} and keep the one you like. "
f"The seed is in every\n filename, so re-run it later without {c['grn']}--fast{c['rst']} for the full-quality take.",
"",
]))
def print_formats():
c = C
specs = getattr(_sfx, "FORMATS", {}) or {}
print(f"{c['b']}{c['cyan']}Hardware formats{c['rst']} {c['dim']}(use with --format NAME){c['rst']}\n")
print(f" {c['grn']}{'none':<10}{c['rst']} {c['dim']}keep the model's own 44.1 kHz stereo output{c['rst']}")
for name in FORMATS:
spec = specs.get(name)
if not spec:
continue
ch = "mono" if spec["mono"] else "stereo"
print(f" {c['grn']}{name:<10}{c['rst']} "
f"{c['dim']}{spec['rate']} Hz · {spec['bits']}-bit · {ch}{c['rst']}")
def print_styles():
c = C
print(f"{c['b']}{c['cyan']}Style guides{c['rst']} "
f"{c['dim']}(append with --style NAME[,NAME2] — combine freely){c['rst']}\n")
if not STYLES:
print(" (none — sounds.json not found)")
return
for name, spec in STYLES.items():
prm = spec.get("prompt", "")
prm = prm if len(prm) <= 58 else prm[:57] + "…"
print(f" {c['grn']}{name:<11}{c['rst']} {c['dim']}{prm}{c['rst']}")
def slug(text):
out = "".join(c if c.isalnum() else "_" for c in text.lower()).strip("_")
return out[:40] or "sound"
def _reprompt_nodes(a, text, seconds):
"""Qwen 3.5 rewrites `text` into the form Stable Audio 3 expects.
Returns (nodes, text_ref) where text_ref is a graph reference usable as a
CLIPTextEncode `text` input. The theme's own wording goes INTO the LLM
input rather than being appended afterwards — the rewriter otherwise has no
idea what pack it is serving and will cheerfully put electric guitars in an
orchestral cue.
The nested sampling params must be dot-namespaced (`sampling_mode.seed`);
`sampling_mode` is a COMFY_DYNAMICCOMBO_V3, which is only discoverable from
/object_info.
"""
category = "Music" if a.music else "SFX"
sysp = SA3_REPROMPT.get(category, "")
if not sysp:
return {}, None
full = (f"{sysp}\n\nInput: {text}\n"
f"Target audio length: {max(1, int(round(seconds)))} seconds.\nOutput:")
return {
"20": {"class_type": "CLIPLoader",
"inputs": {"clip_name": a.reprompt_model, "type": "stable_diffusion"}},
"21": {"class_type": "TextGenerate",
"inputs": {"clip": ["20", 0], "prompt": full, "max_length": 256,
"sampling_mode": "on",
"sampling_mode.temperature": a.reprompt_temp,
"sampling_mode.top_k": 64, "sampling_mode.top_p": 0.95,
"sampling_mode.min_p": 0.05,
"sampling_mode.repetition_penalty": 1.05,
"sampling_mode.seed": 0,
"thinking": False, "use_default_template": True}},
}, ["21", 0]
def _song_nodes(a, seed, subject):
"""ACE-Step 1.5 graph — full songs with vocals, lyrics, BPM and key.
A different engine from the SFX path, but it lands its AUDIO on node "10"
just like _sfx_nodes does, so the shared RetroSFX + save tail is identical.
(Same trick as pixelmon's --animate: swap the pipeline, keep the CLI.)
The all-in-one checkpoint bundles the DiT, the VAE and the Qwen text encoder
— ComfyUI's ACEStep15 declares vae_key_prefix/text_encoder_key_prefix — so a
single CheckpointLoaderSimple supplies all three, unlike Stable Audio which
needs T5 loaded separately.
"""
def encode(tags, lyrics, codes):
return {"class_type": "TextEncodeAceStepAudio1.5",
"inputs": {"clip": ["4", 1], "tags": tags, "lyrics": lyrics,
"seed": seed, "bpm": a.bpm, "duration": a.seconds,
"timesignature": str(a.timesig), "language": a.lang,
"keyscale": a.key, "generate_audio_codes": codes,
"cfg_scale": a.llm_cfg, "temperature": a.temperature,
"top_p": a.top_p, "top_k": a.top_k, "min_p": a.min_p}}
return {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": a.song_base}},
# generate_audio_codes runs an LLM pass that markedly improves quality but
# is slow; it's pure waste on the negative branch, so only the positive
# conditioning pays for it.
"6": encode(subject, a.lyrics, not a.no_audio_codes),
"7": encode(a.negative, "", False),
"9": {"class_type": "EmptyAceStep1.5LatentAudio",
"inputs": {"seconds": a.seconds, "batch_size": 1}},
"3": {"class_type": "KSampler",
"inputs": {"seed": seed, "steps": a.steps, "cfg": a.cfg,
"sampler_name": a.sampler, "scheduler": a.scheduler,
"denoise": 1.0, "model": ["4", 0], "positive": ["6", 0],
"negative": ["7", 0], "latent_image": ["9", 0]}},
"10": {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
}
def build_graph(a, seed, subject=None, server=None):
subject = subject if subject is not None else a.prompt
parts = [subject]
if a.style_add:
parts.append(a.style_add)
# The tail nudges the model toward the right *kind* of audio. "sound effect"
# actively hurts a music request, so --music swaps it out along with the
# negative prompt (see MUSIC_NEGATIVE).
parts.append("high quality, clean recording"
if a.music else "sound effect, high quality, clean recording")
prompt = ", ".join(parts)
negative = a.negative + ((", " + a.style_neg) if a.style_neg else "")
name = slug(subject) if a.batch else (a.name or slug(subject))
tag = f"{a.bpm}bpm" if a.song else a.format
prefix = f"soundmon/{name}_{a.seconds:g}s_{tag}_s{seed}"
if a.song:
# Different engine, same tail — _song_nodes also lands its AUDIO on "10".
g = _song_nodes(a, seed, subject)
g["11"] = {"class_type": "RetroSFX",
"inputs": {"audio": ["10", 0], "format": a.format,
"trim_silence": not a.no_trim, "max_seconds": a.max_seconds,
"threshold_db": a.threshold_db,
"normalize_db": a.normalize_db, "fade_ms": a.fade_ms}}
return _attach_save(a, g, prefix)
# NOTE: the Stable Audio Open checkpoint bundles the DiT + VAE + the two
# seconds-embedders, but NOT the text encoder — it has zero T5 tensors. So
# CheckpointLoaderSimple's CLIP output (slot 1) is unusable and we load T5
# separately via CLIPLoader with type=stable_audio. Wiring slot 1 instead is
# the #1 way to get a confusing failure here.
g = {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": a.base}},
"5": {"class_type": "CLIPLoader",
"inputs": {"clip_name": a.text_encoder, "type": "stable_audio"}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["5", 0], "text": prompt}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["5", 0], "text": negative}},
# Tells the model where in a clip this sits and how long it runs. Stable
# Audio conditions on duration, so this is not cosmetic — it changes the
# shape of what gets generated, not just how much is kept.
"8": {"class_type": "ConditioningStableAudio",
"inputs": {"positive": ["6", 0], "negative": ["7", 0],
"seconds_start": 0.0, "seconds_total": a.seconds}},
"9": {"class_type": "EmptyLatentAudio",
"inputs": {"seconds": a.seconds, "batch_size": 1}},
"3": {"class_type": "KSampler",
"inputs": {"seed": seed, "steps": a.steps, "cfg": a.cfg,
"sampler_name": a.sampler, "scheduler": a.scheduler, "denoise": 1.0,
"model": ["4", 0], "positive": ["8", 0],
"negative": ["8", 1], "latent_image": ["9", 0]}},
"10": {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
"11": {"class_type": "RetroSFX",
"inputs": {"audio": ["10", 0], "format": a.format,
"trim_silence": not a.no_trim, "max_seconds": a.max_seconds,
"threshold_db": a.threshold_db,
"normalize_db": a.normalize_db, "fade_ms": a.fade_ms}},
}
# Prompt rewriter: replace the literal positive text with the LLM's output.
# Only for SA3 — Stable Audio Open 1.0 was not trained this way.
if a.engine == "sa3" and not a.no_reprompt and not a.song:
rp, ref = _reprompt_nodes(a, prompt, a.seconds)
if ref:
g.update(rp)
g["6"]["inputs"]["text"] = ref
return _attach_save(a, g, prefix)
def _attach_save(a, g, prefix):
"""Attach the save node. Shared by both engines — they agree that the
finished AUDIO is on node "11", so everything downstream is identical."""
if a.flac:
g["12"] = {"class_type": "SaveAudio",
"inputs": {"audio": ["11", 0], "filename_prefix": prefix}}
elif a.mp3:
g["12"] = {"class_type": "SaveAudioMP3",
"inputs": {"audio": ["11", 0], "filename_prefix": prefix, "quality": "V0"}}
elif a.opus:
g["12"] = {"class_type": "SaveAudioOpus",
"inputs": {"audio": ["11", 0], "filename_prefix": prefix, "quality": "128k"}}
else:
g["12"] = {"class_type": "SaveSFX",
"inputs": {"audio": ["11", 0], "filename_prefix": prefix,
"bit_depth": str(a.bits)}}
return g
def submit(graph, server=None):
server = server or SERVER
data = json.dumps({"prompt": graph}).encode()
req = urllib.request.Request(f"{server}/prompt", data=data,
headers={"Content-Type": "application/json"})
try:
return json.loads(urllib.request.urlopen(req, timeout=30).read())["prompt_id"]
except urllib.error.HTTPError as e:
sys.exit("ComfyUI rejected the request:\n" + e.read().decode()[:1200])
except urllib.error.URLError:
sys.exit("Couldn't reach ComfyUI at " + server + " — is the server running?")
def wait(pid, server=None, timeout=1800):
server = server or SERVER
for _ in range(timeout):
with urllib.request.urlopen(f"{server}/history/{pid}", timeout=30) as r:
hist = json.loads(r.read())
if pid in hist and hist[pid].get("outputs"):
return hist[pid]["outputs"]
time.sleep(1)
sys.exit("Timed out waiting for the sound.")
def poll(pid, server):
"""One non-blocking /history check; returns the outputs dict, or None if not ready."""
with urllib.request.urlopen(f"{server}/history/{pid}", timeout=30) as r:
hist = json.loads(r.read())
if pid in hist and hist[pid].get("outputs"):
return hist[pid]["outputs"]
return None
def server_up(server):
try:
urllib.request.urlopen(f"{server}/system_stats", timeout=5).read()
return True
except Exception:
return False
def server_has_ckpt(server, ckpt):
"""Does this box actually have `ckpt` installed?
A mixed fleet is the normal case: the SFX models are ~5.3 GB and the song
model another ~9.3 GB, so boxes get provisioned at different times. Without
this check, a --song job dispatched to an SFX-only box comes back as an
opaque ComfyUI validation error partway through a long batch. Cheaper to ask
once at startup and route around it.
"""
try:
with urllib.request.urlopen(f"{server}/object_info/CheckpointLoaderSimple",
timeout=10) as r:
info = json.loads(r.read())
node = info.get("CheckpointLoaderSimple", info)
return ckpt in node["input"]["required"]["ckpt_name"][0]
except Exception:
return False
def _short(url):
return url.split("//", 1)[-1]
def loop_wrap(path, crossfade=2.0):
"""Make a track loop seamlessly by mixing its own tail back over its head.
WHY THIS EXISTS, and why the obvious fix was the wrong one.
Music that plays continuously has to survive its last sample running into
its first. Generated music does not, for a reason that has nothing to do
with this tool: **the model composes an ending.** Asked for 60 seconds it
writes a 60-second piece of music, with a ritardando and a decay, because
that is what its training data does. Measured on raw output, the final two
seconds fall ~40 dB.
The tempting fix is to stop post-processing from touching the endpoints —
`--no-trim --fade-ms 0` — on the theory that trimming and de-click fades are
what flattened the tail. That was measured here and it is **backwards**:
tail level relative to body, souls pack, same models, same prompts
trim on, 5 ms fade -> -30.4 dB (trim cuts into the model's decay)
trim off, no fade -> -66.6 dB (the model's full decay survives)
Trimming *helped*, because near-silence trimming eats most of the composed
fade-out. It just cannot finish the job: a decay is only "silence" at the
very end, so trimming leaves the quiet part it never crossed the threshold
for. No endpoint policy fixes this, because the hole is musical content, not
processing damage.
So: construct the loop instead of hoping for one. Given source S of length
L and a crossfade of X, the output is T = L - X samples:
O[i] = S[i] for i in [X, T)
O[0:X] = S[0:X]*fade_in + S[T:L]*fade_out
The seam is then continuous *by construction*, not by luck. Play O[T-1] into
O[0] and you get S[T-1] into S[T] — adjacent samples of the original take.
The composed ending is still there; it now lands underneath the opening
instead of on top of a hard cut.
Equal-power (sin/cos) curves rather than linear: the two halves are
uncorrelated material, so linear curves lose ~3 dB in the middle of the
blend and you hear a dip pass by once per loop.
Costs X seconds of length — a 60 s render becomes a 58 s loop. Ask the model
for the longer number if the exact duration matters.
Returns (path, seconds_of_output) — or (path, None) if it declined to act.
"""
try:
import numpy as np
import soundfile as sf
except ImportError:
# Same treatment --narrate gets: the dependency is real but optional, so
# a box without it degrades to "not looped" rather than failing the run.
return path, None
try:
info = sf.info(path)
audio, sr = sf.read(path, always_2d=True)
except Exception:
return path, None
n = len(audio)
x = int(sr * crossfade)
# Refuse rather than mangle. Below 4x the crossfade there is not enough
# material left over for the loop to be anything but the crossfade itself.
if x < 1 or n < 4 * x:
return path, None
t = np.linspace(0.0, 1.0, x, endpoint=False, dtype=audio.dtype)[:, None]
out = audio[:n - x].copy()
out[:x] = audio[:x] * np.sin(t * np.pi / 2) + audio[n - x:] * np.cos(t * np.pi / 2)
tmp = os.path.splitext(path)[0] + ".loop.wav"
try:
sf.write(tmp, out, sr, subtype=info.subtype)
os.replace(tmp, path)
except Exception:
if os.path.exists(tmp):
os.remove(tmp)
return path, None
return path, len(out) / sr
def loudness_normalize(path, lufs=-16.0, true_peak=-1.0):
"""Enforce a loudness CEILING and a true-peak ceiling. Attenuate-only.
The RetroSFX node peak-normalizes, which makes every file's tallest sample
match — but not how loud it *sounds*. Measured across a generated pack,
peaks sat within 0.8 dB of each other while integrated loudness spanned
12.3 dB (alarm -8.1 LUFS vs treasure -20.4 LUFS). A dense sustained sound
at -1 dBFS peak is far louder to the ear than a sparse transient one.
So this is the gain stage: measure EBU R128 integrated loudness, and if the
file is louder than `lufs` (or peaks above `true_peak`), pull it down by a
single fixed gain. Nothing is ever boosted — see the note at the gain
calculation for why targeting a level instead of capping one is wrong here.
True peak rather than sample peak, because lossy decode reconstructs
inter-sample peaks above the original samples: ogg files here measured
-0.4 dBTP from a -1.0 dBFS source, which is how you get playback clipping
from a file that looks compliant.
"""
if shutil.which("ffmpeg") is None:
return path
# loudnorm needs enough signal to integrate over. Measured on real output:
# it reports cleanly at 2.97s and 0.60s, and returns nothing at 0.20s and
# 0.12s. So the floor is ~0.4s, NOT the 3s an EBU R128 window suggests — a
# 3s guard silently skipped almost the whole SFX pack, since generated
# effects land at 2.97s. Anything shorter keeps the node's peak
# normalization, and the JSON-parse fallback below catches stragglers.
try:
dur = float(subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", path],
capture_output=True, text=True).stdout.strip())
except (ValueError, FileNotFoundError):
return path
if dur < 0.4:
return path
meas = subprocess.run(
["ffmpeg", "-hide_banner", "-nostats", "-i", path,
"-af", "loudnorm=print_format=json", "-f", "null", "-"],
capture_output=True, text=True).stderr
try:
blob = json.loads(meas[meas.rindex("{"):meas.rindex("}") + 1])
cur_i, cur_tp = float(blob["input_i"]), float(blob["input_tp"])
except (ValueError, KeyError):
return path
# A CEILING, not a target. Only ever attenuate.
#
# Targeting a fixed loudness would mean BOOSTING sparse sounds, and they
# can't take it: a coin-jingle measured 34.4 dB crest factor with its peak
# already at -0.97 dBFS, so reaching -16 LUFS needs +14 dB and would put
# the peak at +13 dBFS. The only ways to get there are clipping it or
# compressing it flat — both destroy exactly what makes a transient read as
# a transient. Quiet-but-punchy is a legitimate sound; too loud is not.
#
# So: pull down anything above the ceiling, leave everything else alone,
# and separately guarantee no true peak exceeds the limit.
gain = min(0.0, lufs - cur_i, true_peak - cur_tp)
if gain > -0.1: # already compliant; don't re-encode
return path
tmp = os.path.splitext(path)[0] + ".ln.wav"
m = f"volume={gain:.2f}dB"
# PRESERVE THE SOURCE'S PCM WIDTH. ffmpeg defaults to 16-bit, so re-encoding
# here silently promoted format-locked 8-bit audio back to 16-bit — throwing
# away the honesty of the format lock and doubling the file for no benefit.
codec = []
try:
import soundfile as _sf
sub = _sf.info(path).subtype
codec = {"PCM_U8": ["-c:a", "pcm_u8"],
"PCM_S8": ["-c:a", "pcm_s8"],
"PCM_16": ["-c:a", "pcm_s16le"],
"PCM_24": ["-c:a", "pcm_s24le"],
"PCM_32": ["-c:a", "pcm_s32le"]}.get(sub, [])
except Exception:
codec = []
r = subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", path, "-af", m] + codec + [tmp],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 0:
os.replace(tmp, path)
elif os.path.exists(tmp):
os.remove(tmp)
return path
def to_flac(path, keep=False):
"""Transcode to FLAC losslessly, client-side. Returns the new path.
Why this exists separately from --flac: that flag attaches a ComfyUI SaveAudio
node, so it only ever applied to the diffusion path. --chip/--opl/--chipfx
write their own files and were stuck with WAV or a lossy OGG.
FLAC is the right container for format-locked audio. Measured on a 6-bit
11 kHz render, distinct amplitude levels surviving the encode:
WAV source 57 OGG q=8 96775 FLAC 57
A bit-crushed signal is maximally hostile to lossy compression: the
quantization steps ARE broadband high-frequency content, which is exactly what
a psychoacoustic model is built to discard. So the crush cannot survive Vorbis
or MP3 at any bitrate — it is not a quality setting, it is the codec working
as designed. FLAC keeps it bit-for-bit, keeps mono mono, and still shrinks the
file.
"""
if shutil.which("ffmpeg") is None:
return path
out = os.path.splitext(path)[0] + ".flac"
# NEVER transcode a file onto itself. ffmpeg refuses, and the cleanup at the
# bottom of this function then removes `path` — which is the same file. That
# deleted every ComfyUI-saved .flac after a successful download.
if os.path.abspath(out) == os.path.abspath(path):
return path
r = subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", path, "-c:a", "flac", "-compression_level", "8", out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if r.returncode != 0 or not os.path.exists(out) or os.path.getsize(out) == 0:
if os.path.exists(out):
os.remove(out)
return path
if not keep:
os.remove(path)
return out
def to_ogg(path, quality=5, keep=False):
"""Transcode a finished file to OGG Vorbis. Returns the new path (or the
original, unchanged, if conversion isn't possible).
Done CLIENT-SIDE, on purpose. The obvious alternative — having the ComfyUI
save node emit .ogg — would need an encoder installed on every farm box and
a node update + restart across the fleet. Converting after the file lands
means unmodified farm boxes keep working and the CLI alone decides the
output format. Encoding a 60s track takes well under a second, so there is
nothing to gain by pushing it upstream.
ffmpeg's *native* vorbis encoder is used with `-strict -2` because this
build ships no libvorbis, and python-soundfile segfaults writing OGG here
(libsndfile 1.2.2, truncates then SIGSEGVs). Measured on a 60s 48kHz track:
11 MB -> 436 KB at q=5, a 26x reduction with the full duration intact.
"""
if shutil.which("ffmpeg") is None:
print(" ⚠ --ogg needs ffmpeg on PATH; leaving WAV")
return path
out = os.path.splitext(path)[0] + ".ogg"
if os.path.abspath(out) == os.path.abspath(path):
return path # same trap as to_flac: would delete its own input
# `-ac 2` is not optional: ffmpeg's native vorbis encoder is STEREO-ONLY
# ("Current FFmpeg Vorbis encoder only supports 2 channels"), and narration
# comes out of Kokoro as 24kHz mono. Upmixing costs almost nothing because
# Vorbis joint-stereo codes two identical channels efficiently.
r = subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", path,
"-c:a", "vorbis", "-strict", "-2", "-ac", "2", "-q:a", str(quality), out],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if r.returncode != 0 or not os.path.exists(out) or os.path.getsize(out) == 0:
print(f" ⚠ ogg encode failed for {os.path.basename(path)}; keeping WAV")
print(" " + r.stderr.decode().strip().splitlines()[0][:160]
if r.stderr else "")
if os.path.exists(out):
os.remove(out) # don't leave a 0-byte .ogg that shadows the WAV
return path
if not keep:
os.remove(path)
return out
def encode(path, a):
"""Encode one finished WAV to the requested delivery format. Returns the path.
ONE place, because this decision was previously made in seven, and --flac was
wired into only three of them. --chip, --opl and --chipfx honoured it; the
diffusion path, --blip, --narrate and --record each had their own
`if a.ogg: to_ogg(...)` and no flac branch at all. Worse than a missing
feature: blip.py and narrate.py both ACCEPT a `to_flac` callable and never
call it, so the flag was accepted, plumbed, and silently dropped.
That is the same failure mode as "two renderers drifting apart because a fix
landed in only one", which is why selfcheck.py exists. So: one function, and
an assertion per engine that it is reached.
FLAC wins when both are asked for. It is lossless, and for the bit-crushed
output these engines produce a lossy codec destroys the very thing that makes
it sound like hardware.
IDEMPOTENT, and that is load-bearing rather than tidy. On the diffusion path
--flac attaches a ComfyUI SaveAudio node, so the server already returns a
.flac; re-encoding it resolved the output to the SAME path, ffmpeg refused to
write it, and the `if not keep: os.remove(path)` at the end deleted the file.
Every asset downloaded fine and was then destroyed, reported as "nothing
produced". So: if the file is already in the requested format, leave it alone.
"""
want = "flac" if getattr(a, "flac", False) else (
"ogg" if getattr(a, "ogg", False) else None)
if want is None or path.lower().endswith("." + want):
return path
if want == "flac":
return to_flac(path, getattr(a, "keep_wav", False))
return to_ogg(path, getattr(a, "ogg_quality", 5),
getattr(a, "keep_wav", False))
def encode_all(paths, a):
"""encode() over a list, for the diffusion path's batches."""
return [encode(p, a) for p in paths]
def audio_outs(outs):
"""Flatten ComfyUI's outputs dict to the list of saved audio files.
Save nodes report under 'audio' (SaveAudio, SaveAudioMP3/Opus, and our SaveSFX)."""
return [f for node in outs.values() for f in node.get("audio", [])]
def fetch_audio(item, dest_dir, server=None):
"""Download one server-side output file via /view into dest_dir; return local path."""
import urllib.parse
server = server or SERVER
q = urllib.parse.urlencode({"filename": item["filename"],
"subfolder": item.get("subfolder", ""),
"type": item.get("type", "output")})
os.makedirs(dest_dir, exist_ok=True)
out = os.path.join(dest_dir, item["filename"])
with urllib.request.urlopen(f"{server}/view?{q}", timeout=300) as r, open(out, "wb") as f:
shutil.copyfileobj(r, f)
return out
def run_farm(a, work):
"""Render farm: distribute jobs across POOL with dynamic dispatch (feed the free GPU).
Faster boxes naturally pull more jobs; results are fetched back from whichever GPU made them."""
live = [s for s in POOL if server_up(s)]
down = [s for s in POOL if s not in live]
if down:
print(f" ⚠ skipping unreachable: {', '.join(_short(s) for s in down)}")
if not live:
sys.exit("render farm: no reachable servers in the pool.")
# Route around boxes that lack the model this run needs, rather than letting
# them fail jobs mid-batch.
need = a.song_base if a.song else a.base
ready = [s for s in live if server_has_ckpt(s, need)]
missing = [s for s in live if s not in ready]
if missing:
print(f" ⚠ skipping (no {need}): {', '.join(_short(s) for s in missing)}")
hint = "--song" if a.song else ""
print(f" provision them with: ./download-models.sh {hint}".rstrip())
if not ready:
sys.exit(f"render farm: no server in the pool has {need}.")
live = ready
print(f" \U0001f69c render farm: {len(live)} GPU(s) — {', '.join(_short(s) for s in live)}")
pending = list(work) # (subject, seed, dest)
inflight = {} # server -> (subject, seed, dest, pid)
total = len(work)
done = 0
def launch(srv):
"""Submit the next pending job to srv. False = server unusable (drop it)."""
while pending:
subj, seed, d = pending.pop(0)
try:
pid = submit(build_graph(a, seed, subject=subj, server=srv), srv)
except SystemExit:
pending.insert(0, (subj, seed, d)) # couldn't submit; keep the job
return False
inflight[srv] = (subj, seed, d, pid)
return True
return True # nothing left to do
for srv in list(live):
if launch(srv) is False:
live.remove(srv)
while inflight:
advanced = False
for srv, (subj, seed, d, pid) in list(inflight.items()):
try:
outs = poll(pid, srv)
except Exception:
print(f" ⚠ {_short(srv)} unreachable — requeueing its job")
pending.append((subj, seed, d))
del inflight[srv]
advanced = True
continue
if outs is None:
continue
advanced = True
dest_dir = d or os.path.join(OUTPUT, "soundmon")
files = [fetch_audio(it, dest_dir, srv) for it in audio_outs(outs)]
# Before loudness: the wrap MIXES two signals, so it changes level.
# Anything that changes gain after normalizing invalidates it.
if a.loop:
for f in files:
loop_wrap(f, a.loop_crossfade)
if a.lufs_target is not None:
files = [loudness_normalize(f, a.lufs_target, a.true_peak) for f in files]
files = encode_all(files, a)
done += 1
sj = f"{subj} " if a.batch else ""
print(f" ✅ [{done}/{total}] {_short(srv):<20} {sj}seed={seed} -> "
f"{files[0] if files else '(no file)'}")
del inflight[srv]
launch(srv) # feed the now-free GPU its next job
if not advanced:
time.sleep(1)
if pending:
print(f" ⚠ {len(pending)} job(s) left undone (all GPUs dropped).")
def main():
p = argparse.ArgumentParser(prog="soundmon", add_help=False)
p.add_argument("-h", "--help", action="store_true", dest="show_help")
p.add_argument("prompt", nargs="?", help='the sound you want, e.g. "a heavy door creaking"')
p.add_argument("-n", "--number", type=int, default=1,
help="how many to generate, each with a different seed. default 1 "
"(with --batch: how many of EACH subject)")
p.add_argument("--batch", default=None, metavar="SUBJECTS",
help='comma-separated subjects to round-robin, one of each per pass; '
'each goes to its own folder')
p.add_argument("--seconds", type=float, default=10.0,
help="length in seconds (model max 47). default 10. The model conditions "
"on this, so it shapes the sound — not just its length.")
p.add_argument("--style", default="",
help="style guide(s) to append, comma-separated. see --list-styles")
p.add_argument("--format", default="none",
help="hardware format lock (sample rate + bit depth + channels). "
"'none' = the model's own 44.1 kHz stereo. see --list-formats")
p.add_argument("--bits", type=int, default=16, choices=[8, 16, 24],
help="WAV bit depth. default 16")
p.add_argument("--steps", type=int, default=None, help="sampling steps (default 50, or 16 with --fast)")
p.add_argument("--cfg", type=float, default=5.0, help="prompt adherence. default 5.0")
p.add_argument("--fast", action="store_true", help="16 steps: ~3x faster, rougher")
p.add_argument("--seed", type=int, default=-1, help="-1 = random each run")
# --- chip mode: a real PSG synthesizer. No model, no GPU, no ComfyUI. ---
p.add_argument("--chip", action="store_true",
help="CHIPTUNE mode — synthesize real 2A03 chiptune (2 pulse + "
"triangle + noise). Authentic by construction; loops seamlessly.")
# --scale, not --chip-scale: opl.py shares chip.compose(), so this drives
# AdLib as well. The old name stays as an alias — it is in scripts already.
p.add_argument("--scale", "--chip-scale", dest="chip_scale", default=None,
choices=["minor", "harmonic", "dorian", "phrygian", "major",
"mixolydian", "pentatonic"],
help="override the scale implied by --key")
p.add_argument("--write-mod", dest="write_mod", action="store_true",
help="ALSO write a ProTracker .MOD (--chip). Real samples from "
"the PSG synth plus pattern data — editable in OpenMPT or "
"Schism, and tiny.")
p.add_argument("--write-rad", dest="write_rad", action="store_true",
help="ALSO write a Reality AdLib Tracker .RAD (--opl). OPL "
"instruments from the bank plus patterns — QB64 plays it "
"natively.")
p.add_argument("--write-midi", dest="write_midi", action="store_true",
help="ALSO write a .mid beside the audio (--chip/--opl). A few "
"KB, plays natively in QB64, and can be paired with any "
"soundfont — the audio render is unaffected.")
p.add_argument("--from-midi", dest="from_midi", default=None, metavar="FILE",
help="play a MIDI FILE on the chip. The BEST input: exact "
"pitches, real note durations, explicit tempo and time "
"signature, drums already separated. No inference at all.")
p.add_argument("--midi-start", dest="midi_start", type=float, default=0.15,
metavar="F",
help="skip this fraction into the file before taking bars "
"(game MIDIs often open with empty bars)")
p.add_argument("--transpose", type=int, default=0, metavar="N",
help="shift --from-midi by N semitones")
p.add_argument("--from-audio", dest="from_audio", default=None, metavar="FILE",
help="TRANSCRIBE a reference recording and play it on the chip. "
"Takes the tempo, key, chords, melody, bass and drums from "
"FILE — the composition is the reference's, the sound is the "
"chip's. Mood still chooses the voices.")
p.add_argument("--mood", default="auto",
help="mood for --chip/--opl: heroic, ominous, eerie, melancholy, "
"solemn, mysterious, tense, frantic, driving, playful, serene, "
"grand, wondrous, triumphant. 'auto' reads it from your "
"description. --list-moods")
p.add_argument("--list-moods", dest="list_moods", action="store_true",
help="show every mood and what it changes")
p.add_argument("--chippy", default="off",
choices=["off", "some", "lots", "max"],
help="how CHIP the chip sounds (--chip --from-midi). Chords "
"become fast arpeggios, leaps get pitch slides, and at "
"'max' a subtle tracker-style echo. off = exact polyphony.")
p.add_argument("--arp", "--chip-arp", dest="chip_arp", type=int, default=0, metavar="N",
help="arpeggio speed in 16ths — 1 is the classic buzzy chord (default: 1)")
# --- opl mode: real AdLib / Sound Blaster FM via the Nuked OPL3 core ---
p.add_argument("--opl", action="store_true",
help="ADLIB/OPL3 mode — drive a cycle-accurate Nuked OPL3 core. "
"Real FM synthesis, not an imitation. Build it with "
"./download-models.sh --opl")
p.add_argument("--opl-bank", dest="opl_bank", default=None, metavar="FILE",
help="load an external patch bank (.sbi). Omit for the built-in bank")
p.add_argument("--opl-lead", dest="opl_lead", default="brass",
help="instrument for the lead voice (default: brass)")
p.add_argument("--opl-arp", dest="opl_arp", default="organ",
help="instrument for the arpeggio voice (default: organ)")
p.add_argument("--opl-bass", dest="opl_bass", default="bass",
help="instrument for the bass voice (default: bass)")
p.add_argument("--opl-lib", dest="opl_lib", default=None, metavar="PATH",
help="explicit path to libopl3.so (default: auto-detect)")
p.add_argument("--chipfx", action="store_true",
help="CHIP SFX mode — synthesize an 8-bit sound effect (PSG: "
"pulse/triangle/LFSR noise), sfxr-style. No model.")
p.add_argument("--oplfx", action="store_true",
help="OPL SFX mode — the same effect recipes rendered through "
"the Nuked OPL3 FM core instead of a PSG.")
p.add_argument("--fx-archetype", dest="fx_archetype", default=None, metavar="NAME",
help="force the effect archetype instead of inferring it from "
"the name/description (e.g. hit, boom, coin, creak)")
# --- blip mode: JRPG text-box narration, CPU, and 'synth' needs no model ---
p.add_argument("--blip", action="store_true",
help="BLIP mode — JRPG text-box narration (Undertale / Animal "
"Crossing style). 'synth' needs no model at all.")
p.add_argument("--blip-file", dest="blip_file", default=None, metavar="FILE",
help="blip every line of a file — same 'key | text' rows --narrate-file reads")
p.add_argument("--blip-style", dest="blip_style", default="synth",
choices=["synth", "voice"],
help="synth = one oscillator blip per character (Undertale, no model); "