-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsynth.py
More file actions
362 lines (306 loc) · 15 KB
/
Copy pathsynth.py
File metadata and controls
362 lines (306 loc) · 15 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
"""`synth` command — eligible rows → VoiceVox audio → batch MP3, stamp D (issue 003).
Flow (PRD F2 steps 1-3, 5): select eligible rows, synthesize each Japanese
sentence via the local VoiceVox HTTP API, assemble a batch MP3 whose per-sentence
block is `sentence → pause → sentence → gap`, then stamp column D = batch number
on every included row. On any synthesis/assembly failure, abort before stamping D
(PRD F3) — D is written only after all outputs exist, so a failed run leaves rows
eligible for the next run.
Seams (injected for tests, default to the real implementations):
synthesize_fn(text) -> WAV bytes (default: VoiceVox HTTP over urllib)
assemble_fn(plan, mp3_path) (default: ffmpeg subprocess)
"""
import datetime
import json
import os
import random
import subprocess
import urllib.parse
import urllib.request
from pipeline import COL_BATCH, COL_ID, assign_ids, classify
# --- tuning knobs (PRD §8: tuned by ear in the batch-01 shakedown) ----------
# Pause between the two repeats of a sentence; PAUSE_RATIO multiplies the
# sentence's own audio length (≈ sentence length per PRD §8).
PAUSE_RATIO = 1.0
# Silent gap after each per-sentence block, in seconds.
GAP_SECONDS = 1.5
# VoiceVox: local HTTP API base, and the hardcoded speaker style id.
# 29 = No.7 / ノーマル — chosen by ear (clear, announcer-like) over 剣崎雌雄 (21),
# which sounded tinny, and the other male voices tried.
VOICEVOX_URL = "http://localhost:50021"
VOICEVOX_SPEAKER = 29
# Cover for the YouTube mp4 (issue 004): static placeholder per PRD §8 —
# a solid-color frame generated by ffmpeg's lavfi source, so no image asset
# is needed. Swap for a real image / per-batch overlay only if this ever hurts.
COVER_SOURCE = "color=c=0x1a1a2e:s=1280x720:r=1"
# On-screen Sentence ID overlay (ADR-0001): the permanent ID burned large and
# centered on the cover, changing per sentence in play order. Tune by eye like
# the pause/gap knobs. The font must exist on the box running synth (macOS stock).
FONT_FILE = "/System/Library/Fonts/Helvetica.ttc"
OVERLAY_FONTSIZE = 240
OVERLAY_FONTCOLOR = "white"
# --- sample-quality knobs (tune by ear like pause/gap) -----------------------
# VoiceVox audio_query tuning, applied to every sentence:
SPEED_SCALE = 0.9 # slightly slower than the default 1.0
INTONATION_SCALE = 1.1 # slightly more expressive pitch contours
# Encode targets — both produced directly from the lossless WAV master, never
# lossy-from-lossy:
MP3_QUALITY = "2" # LAME VBR -q scale; 2 ≈ 190 kbps
AAC_BITRATE = "192k" # mp4 audio
# EBU R128 loudness normalization on the assembled master, for consistent
# perceived volume across sentences and batches:
LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11"
def cmd_synth(
sheet,
synthesize_fn=None,
assemble_fn=None,
encode_fn=None,
duration_fn=None,
wrap_fn=None,
shuffle_fn=None,
output_dir="output",
today=None,
) -> int:
if synthesize_fn is None:
synthesize_fn = voicevox_synthesize
if assemble_fn is None:
assemble_fn = ffmpeg_assemble
if encode_fn is None:
encode_fn = ffmpeg_encode_mp3
if duration_fn is None:
duration_fn = ffprobe_duration
if wrap_fn is None:
wrap_fn = ffmpeg_wrap_mp4
if shuffle_fn is None:
shuffle_fn = random.shuffle
if today is None:
today = datetime.date.today()
rows = sheet.fetch_rows()
eligible = [r for r in rows if classify(r) == "eligible"]
if not eligible:
print("synth: no eligible rows — nothing to do.")
return 0
# Shuffle the play order once, in place, right after selection (ADR-0001):
# the synth loop, assembly plan, both SRTs, and the on-screen ID overlay all
# iterate this one list downstream, so they share a single order and the
# captions can't drift off the audio. The sheet stays in capture order.
shuffle_fn(eligible)
batch_number = next_batch_number(rows)
# backfill permanent Sentence IDs (ADR-0001) before building outputs, so the
# on-screen overlay can read each eligible row's sid; persisted only on
# success, together with the batch stamp (F3: a failed run writes nothing).
id_updates = assign_ids(rows)
batch_name = f"batch{batch_number:02d}"
batch_dir = os.path.join(output_dir, batch_name)
os.makedirs(batch_dir, exist_ok=True)
mp3_path = os.path.join(output_dir, f"{batch_name}.mp3")
mp4_path = os.path.join(output_dir, f"{batch_name}.mp4")
title = f"Batch {batch_number:02d} – {today.isoformat()}" # PRD F2.5: title carries the date
try:
wav_paths = []
for row in eligible:
wav = synthesize_fn(row.japanese)
wav_path = os.path.join(batch_dir, f"sentence-{row.index:03d}.wav")
with open(wav_path, "wb") as f:
f.write(wav)
wav_paths.append(wav_path)
durations = [duration_fn(p) for p in wav_paths]
plan = assembly_plan(wav_paths, durations)
# one lossless, loudness-normalized master; mp3 and mp4 audio are each
# encoded straight from it (never lossy-from-lossy)
master_path = os.path.join(batch_dir, "master.wav")
assemble_fn(plan, master_path)
encode_fn(master_path, mp3_path)
# burn the permanent Sentence ID on screen per sentence, in play order,
# so a shuffled batch stays navigable back to the sheet (ADR-0001)
overlay = overlay_cues([row.sid for row in eligible], durations)
wrap_fn(master_path, mp4_path, title, overlay)
# two caption tracks (issues 006/007): kanji-only, and furigana from
# column E falling back to kanji for rows without a reading
srt_path = os.path.join(output_dir, f"{batch_name}.srt")
with open(srt_path, "w", encoding="utf-8") as f:
f.write(srt_for([row.japanese for row in eligible], durations))
furigana_path = os.path.join(output_dir, f"{batch_name}.furigana.srt")
with open(furigana_path, "w", encoding="utf-8") as f:
f.write(srt_for(
[row.reading.strip() or row.japanese for row in eligible], durations
))
except Exception as exc: # any VoiceVox / ffmpeg failure: abort before stamping D
print(f"synth aborted: {exc}")
return 1
batch_updates = [(row.index, COL_BATCH, str(batch_number)) for row in eligible]
sheet.write_cells(id_updates + batch_updates)
return 0
def assembly_plan(wav_paths, durations):
"""Ordered segments for the batch track (PRD §8 rhythm), pure & ffmpeg-free.
Per-sentence block: audio → pause → audio → gap, blocks in sheet order.
Pause ≈ the sentence's own length (PAUSE_RATIO × duration); gap = GAP_SECONDS.
Segments are ('audio', path) or ('silence', seconds) — the renderer's input.
"""
plan = []
for path, duration in zip(wav_paths, durations):
plan.append(("audio", path))
plan.append(("silence", duration * PAUSE_RATIO))
plan.append(("audio", path))
plan.append(("silence", GAP_SECONDS))
return plan
def _block_seconds(duration: float) -> float:
"""Length of one sentence block: audio → pause(≈len) → audio (no trailing gap).
The single source of the block-timing math (PRD §8) shared by the caption
track and the on-screen ID overlay, so the two can never drift apart.
"""
return duration + duration * PAUSE_RATIO + duration
def srt_for(texts, durations) -> str:
"""SRT subtitle track: one cue per sentence block (issue 006).
Cue span mirrors assembly_plan's block math — from the first playback
through the second (audio → pause → audio), ending where the gap starts,
so the gap stays text-free. Uploaded as a YouTube caption track the text
is toggleable: listen blind first, flip captions on to verify.
"""
cues = []
t = 0.0
for number, (text, duration) in enumerate(zip(texts, durations), start=1):
block = _block_seconds(duration)
cues.append(f"{number}\n{_srt_time(t)} --> {_srt_time(t + block)}\n{text}\n")
t += block + GAP_SECONDS
return "\n".join(cues)
def overlay_cues(ids, durations):
"""Timeline for the burned-in on-screen Sentence ID (ADR-0001), pure & ffmpeg-free.
One cue per sentence: ('id', start, end). A cue spans its whole block
(audio → pause → audio, the same math as srt_for) PLUS its trailing gap, so
the cues are contiguous — the previous ID stays on screen through the gap
until the next one replaces it, and the last reaches the end of the track.
No blank frames; the ID is the listener's handle back to the sheet when the
play order is shuffled. The renderer turns each cue into one timed drawtext.
"""
cues = []
t = 0.0
for sid, duration in zip(ids, durations):
span = _block_seconds(duration) + GAP_SECONDS
cues.append((sid, t, t + span))
t += span
return cues
def _srt_time(seconds: float) -> str:
"""SRT timestamp: HH:MM:SS,mmm."""
ms = round(seconds * 1000)
hours, ms = divmod(ms, 3_600_000)
minutes, ms = divmod(ms, 60_000)
secs, ms = divmod(ms, 1_000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{ms:03d}"
def next_batch_number(rows) -> int:
"""Next batch number = max existing numeric value in column D + 1 (1 if none)."""
used = [int(r.batch) for r in rows if r.batch.strip().isdigit()]
return max(used, default=0) + 1
# --- real seam implementations (defaults; faked in tests) -------------------
def apply_voice_tuning(query: dict) -> dict:
"""Apply the sample-quality knobs to a VoiceVox audio_query (pure)."""
query["speedScale"] = SPEED_SCALE
query["intonationScale"] = INTONATION_SCALE
return query
def voicevox_synthesize(text: str) -> bytes:
"""Synthesize Japanese `text` to WAV bytes via the local VoiceVox HTTP API.
Two-step flow (PRD F2): POST /audio_query?text=...&speaker=21 to get the
query JSON, tune it (speed/intonation knobs), then POST /synthesis with
the query as body. interrogative_upspeak gives questions a natural rise.
"""
query_url = f"{VOICEVOX_URL}/audio_query?" + urllib.parse.urlencode(
{"text": text, "speaker": VOICEVOX_SPEAKER}
)
with urllib.request.urlopen(urllib.request.Request(query_url, method="POST")) as resp:
query = apply_voice_tuning(json.loads(resp.read()))
synth_url = f"{VOICEVOX_URL}/synthesis?" + urllib.parse.urlencode(
{"speaker": VOICEVOX_SPEAKER, "enable_interrogative_upspeak": "true"}
)
req = urllib.request.Request(
synth_url, data=json.dumps(query).encode("utf-8"), method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
return resp.read()
def ffprobe_duration(path: str) -> float:
"""Return the duration of an audio file in seconds, via ffprobe."""
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
check=True, capture_output=True, text=True,
)
return float(out.stdout.strip())
def ffmpeg_encode_mp3(wav_path: str, mp3_path: str) -> None:
"""Encode the WAV master to the practice-track mp3 (LAME VBR, MP3_QUALITY)."""
subprocess.run(
["ffmpeg", "-y", "-i", wav_path, "-q:a", MP3_QUALITY, mp3_path],
check=True, capture_output=True,
)
def _overlay_filtergraph(overlay) -> str:
"""Chain one timed drawtext per overlay cue onto the cover video stream.
Each cue ('id', start, end) becomes a centered drawtext enabled only over
its span (ADR-0001). Commas in between()/expressions are protected by single
quotes so the filtergraph parser doesn't split on them.
"""
draws = ",".join(
f"drawtext=fontfile={FONT_FILE}:text='{sid}':fontcolor={OVERLAY_FONTCOLOR}"
f":fontsize={OVERLAY_FONTSIZE}:x=(w-text_w)/2:y=(h-text_h)/2"
f":enable='between(t,{start:.3f},{end:.3f})'"
for sid, start, end in overlay
)
return f"[0:v]{draws}[v]"
def ffmpeg_wrap_mp4(audio_path: str, mp4_path: str, title: str, overlay=None) -> None:
"""Wrap the WAV master in a YouTube-ready mp4 (issue 004, PRD F2.4).
One ffmpeg command: cover frame (lavfi color source) + the master, h264
still-image video, AAC audio encoded directly from the lossless master,
dated title as container metadata, faststart for upload. Raises on failure
(caller aborts before stamping D).
When `overlay` cues are given (ADR-0001) the cover gains a timed drawtext
per sentence — the permanent Sentence ID burned centered, in play order —
via -filter_complex; with no overlay the cover stays a single static frame.
The cover source is explicitly bounded to the audio's length: an unbounded
lavfi source generates frames faster than the audio is consumed, so once
the track outgrows the mux buffer, -shortest alone overshoots by minutes
of frozen cover (observed: 10.6s audio → 94s video).
"""
if overlay:
video_args = ["-filter_complex", _overlay_filtergraph(overlay), "-map", "[v]"]
else:
video_args = ["-map", "0:v"]
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-t", f"{ffprobe_duration(audio_path)}", "-i", COVER_SOURCE,
"-i", audio_path,
*video_args, "-map", "1:a",
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", AAC_BITRATE,
"-metadata", f"title={title}",
"-movflags", "+faststart",
"-shortest", mp4_path,
]
subprocess.run(cmd, check=True, capture_output=True)
def ffmpeg_assemble(plan, wav_path: str) -> None:
"""Render an assembly_plan (audio/silence segments) to the WAV master.
Each segment becomes an input: real files directly, silences via the
anullsrc source trimmed to length. The concat filter joins them in order,
loudness normalization (LOUDNORM) runs on the joined track, and the result
stays lossless PCM — the encodes to mp3/AAC happen from this master.
Raises on any ffmpeg failure (caller aborts before stamping D).
"""
inputs, filters, labels = [], [], []
for i, (kind, value) in enumerate(plan):
if kind == "audio":
inputs += ["-i", value]
else: # silence
inputs += [
"-f", "lavfi", "-t", f"{value}",
"-i", "anullsrc=channel_layout=mono:sample_rate=24000",
]
# normalize every segment to a common format before concat
filters.append(
f"[{i}:a]aformat=sample_fmts=s16:sample_rates=24000:channel_layouts=mono[s{i}]"
)
labels.append(f"[s{i}]")
# loudnorm internally upsamples to 192 kHz — resample back to the
# model-native 24 kHz so the master doesn't carry fake resolution
filtergraph = (
";".join(filters) + ";" + "".join(labels)
+ f"concat=n={len(plan)}:v=0:a=1[cat];[cat]{LOUDNORM},aresample=24000[out]"
)
cmd = ["ffmpeg", "-y", *inputs, "-filter_complex", filtergraph,
"-map", "[out]", "-c:a", "pcm_s16le", wav_path]
subprocess.run(cmd, check=True, capture_output=True)