-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmake_print.py
More file actions
210 lines (182 loc) · 9.66 KB
/
Copy pathmake_print.py
File metadata and controls
210 lines (182 loc) · 9.66 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
#!/usr/bin/env python3
"""Audio in, print-ready PDF out: the whole outbound half of the pipeline.
python make_print.py -i song.mp3 [-o out] [--paper a4] [--dpi 600] [--width 39]
[--margin 9] [--fit [SHEETS]] [--landscape] [--align left]
[--bilevel] [--only-wav]
Replaces the hand arithmetic that 01.sh left to you. The page geometry decides
everything else: one image row is one audio sample and one strip is one second,
so the sample rate has to equal the usable page height in pixels, and how many
seconds fit on a sheet is the usable width divided by the strip width. Get the
rate wrong and the playback speed is wrong; this works it out from --paper,
--dpi and --margin instead, through page.py, which is the one place that knows
what a sheet of paper is.
Stages, all of them skippable with --only-wav:
1. ffmpeg | sox band-limit, compress, resample to the page height
2. wav2png lay the strips out into sheets, tagged with --dpi
(the last one as narrow as its strip count needs)
3. sheet2pdf one page per sheet, placed 1:1, embedded losslessly
ffmpeg and sox are taken from ./sox/ if that folder has them (the Windows
layout), otherwise from PATH (the usual Linux layout). Everything else is
this repo plus numpy and Pillow, so the script runs the same either way.
--width is the trade: wider strips resolve amplitude better, narrower ones fit
more seconds on the page. At 600 dpi, 39 px is 1.7 mm and about 5.3 bits;
doubling it buys one bit and halves the seconds per sheet.
--fit picks that width for you: the widest strip that still gets the whole
recording onto one sheet, or onto SHEETS of them. Widest is the point --
spare page width is amplitude resolution thrown away, and the strips it would
buy get filled with silence anyway. The length is only known once stage 1 has
resampled the audio, so --fit is resolved after the WAV exists, not before.
"""
import argparse
import glob
import math
import os
import shutil
import subprocess
import sys
import wave
import page
import sheet2pdf
import wav2png
SOX_CHAIN = ["highpass", "150",
"compand", "0.01,0.15", "6:-30,-30,0,-15", "4", "-90", "0.05",
"equalizer", "2800", "1.2q", "+3"]
def tool(name):
"""ffmpeg/sox from the bundled ./sox/ folder, else from PATH."""
local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sox",
name + (".exe" if os.name == "nt" else ""))
found = local if os.path.exists(local) else shutil.which(name)
if not found:
raise SystemExit("%s not found in ./sox/ nor on PATH" % name)
return found
def make_wav(src, dst, rate):
"""ffmpeg | sox: mono, band-limited, compressed, resampled to `rate`."""
# pcm_u8 is the intermediate 01.sh used; it caps the chain near 48 dB, still
# above what paper gives back. The output itself is written 16-bit.
ff = [tool("ffmpeg"), "-v", "error", "-y", "-i", src,
"-ar", "44100", "-ac", "1", "-acodec", "pcm_u8", "-f", "u8", "-"]
sx = [tool("sox"), "-t", "u8", "-r", "44100", "-c", "1", "-", "-b", "16", dst] \
+ SOX_CHAIN + ["rate", "-v", str(rate), "gain", "-n", "-0.5"]
p1 = subprocess.Popen(ff, stdout=subprocess.PIPE)
p2 = subprocess.Popen(sx, stdin=p1.stdout)
p1.stdout.close() # so ffmpeg sees the pipe close if sox dies
if p2.wait() or p1.wait():
raise SystemExit("ffmpeg/sox failed")
if not os.path.exists(dst):
raise SystemExit("%s: not written" % dst)
def run(src, outdir, dpi=600, width=39, margin=9.0, landscape=False,
bilevel=False, only_wav=False, rate=None, fit=0, paper="a4",
align="left"):
# --fit does not know the width yet; the rate never depended on it anyway
fit_rate, per_sheet = page.geometry(paper, dpi, 1 if fit else width, margin, landscape)
if rate and rate != fit_rate:
print("note: --rate %d instead of the %d that fills the page; the sheet "
"will be %.1f mm tall" % (rate, fit_rate, rate / dpi * 25.4))
rate = rate or fit_rate
os.makedirs(outdir, exist_ok=True)
wav = os.path.join(outdir, "audio.wav")
print("page : %s %s, %g mm margins, %g dpi"
% (paper, "landscape" if landscape else "portrait", margin, dpi))
print("\n[1/3] ffmpeg | sox -> %s" % wav)
make_wav(src, wav, rate)
if fit:
with wave.open(wav) as w:
seconds = w.getnframes() / float(w.getframerate())
# fit's own count, not geometry's: it spreads the strips evenly over
# the sheets asked for instead of filling early pages and stranding the last
per_sheet, width = page.fit(paper, dpi, margin, landscape,
math.ceil(seconds), fit)
print("fit : %.1f s onto %d sheet%s -> --width %d"
% (seconds, fit, "" if fit == 1 else "s", width))
print("strip : %d px = %.2f mm wide, %d px tall -> %d Hz, ~%.1f bits of amplitude"
% (width, width / dpi * 25.4, rate, rate, math.log2(width)))
print("sheet : %d strips = %d seconds, %.1f x %.1f mm"
% (per_sheet, per_sheet, per_sheet * width / dpi * 25.4, rate / dpi * 25.4))
if only_wav:
print("done (--only-wav)")
return wav
print("\n[2/3] wav2png")
sheets = os.path.join(outdir, "sheets")
wav2png.convert(wav, sheets, width, per_sheet, dpi, paper=paper,
margin=margin, landscape=landscape, hint=False)
print("\n[3/3] sheet2pdf")
pdf = os.path.join(outdir, "print.pdf")
pages = sorted(glob.glob(os.path.join(sheets, "sheet_*.png")))
sheet2pdf.convert(pages, pdf, dpi, bilevel, paper, align)
print("\nto read the print back, scanning at the same %d dpi:" % dpi)
for line in wav2png.cut_command(width, rate, dpi):
print(line)
print(' python picky.py x 50 1 d 1 1 0.95 idp %d 24 "strips/strip_*.tif"' % rate)
return pdf
def demo():
"""A short synthetic track through every stage, if ffmpeg and sox are around."""
import tempfile
import numpy as np
from PIL import Image
page.demo() # the geometry this all hangs off
try:
tool("ffmpeg"), tool("sox")
except SystemExit as e:
print("demo ok (geometry only, %s)" % e)
return
with tempfile.TemporaryDirectory() as d:
src = os.path.join(d, "tone.wav")
t = np.arange(44100 * 3) / 44100.0
pcm = np.round(0.7 * np.sin(2 * np.pi * 440 * t) * 32767).astype("<i2")
with wave.open(src, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(pcm.tobytes())
# a deliberately tiny page: 2 strips per sheet, so 3 seconds spill onto
# a second sheet and the short last sheet gets exercised too
pdf = run(src, os.path.join(d, "out"), dpi=100, width=20, margin=98)
assert os.path.exists(pdf)
sheets = sorted(glob.glob(os.path.join(d, "out", "sheets", "*.png")))
rate, per_sheet = page.geometry("a4", 100, 20, 98)
assert len(sheets) == 2, sheets
with Image.open(sheets[0]) as im:
assert im.size == (per_sheet * 20, rate), im.size
assert round(im.info["dpi"][0]) == 100
# the last sheet carries one strip and is one strip wide
with Image.open(sheets[1]) as last:
assert last.size == (20, rate), last.size
raw = open(pdf, "rb").read()
assert raw.count(b"/MediaBox") == 2 and b"/DCTDecode" not in raw
# the same 3 seconds with --fit: one page, wider strips, no spare column
pdf = run(src, os.path.join(d, "fit"), dpi=100, width=20, margin=98, fit=1)
assert open(pdf, "rb").read().count(b"/MediaBox") == 1
one = sorted(glob.glob(os.path.join(d, "fit", "sheets", "*.png")))
assert len(one) == 1, one
with Image.open(one[0]) as im:
assert im.size[0] == 3 * page.fit("a4", 100, 98, False, 3, 1)[1], im.size
# and that width is the widest one that fits: one pixel more would not
assert page.geometry("a4", 100, im.size[0] // 3 + 1, 98)[1] < 3
print("demo ok")
if __name__ == "__main__":
if "--demo" in sys.argv:
demo()
raise SystemExit
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("-i", "--input", required=True, help="any audio file ffmpeg reads")
p.add_argument("-o", "--output", help="output folder (default: <input>_print)")
page.add_page_args(p)
page.add_align_arg(p)
p.add_argument("--width", type=int, default=39, metavar="PX",
help="strip width in pixels: amplitude resolution against "
"seconds per sheet (default: 39, i.e. 1.7 mm at 600 dpi)")
p.add_argument("--fit", type=int, nargs="?", const=1, default=0, metavar="SHEETS",
help="ignore --width: use the widest strip that still fits the "
"whole recording onto SHEETS pages (default: 1)")
p.add_argument("--bilevel", action="store_true",
help="threshold the sheets to 1 bit before embedding them")
p.add_argument("--only-wav", action="store_true", help="stop after stage 1")
p.add_argument("--rate", type=int, metavar="HZ",
help="override the sample rate the page geometry implies, to "
"match an existing set of sheets; the page then decides "
"nothing but the strip count")
a = p.parse_args()
run(a.input, a.output or os.path.splitext(a.input)[0] + "_print", a.dpi,
a.width, a.margin, a.landscape, a.bilevel, a.only_wav, a.rate,
a.fit, a.paper, a.align)