Skip to content

Repository files navigation

Split — sound on paper

Write sound onto a sheet of paper as a variable-area optical soundtrack, print it, scan it, and get a WAV back.

 song.mp3 ──ffmpeg|sox──> audio.wav ──wav2png──> sheet_*.png ──sheet2pdf──> print.pdf
                                                                              │
                                                                            print
                                                                              ↓
 out.wav <──picky──── strip_*.tif <──cut_strips──── scan.png <──scan──── paper

Every audio sample is one image row. An ink bar grows from the left edge of the strip, its length follows the signal, and the boundary pixel is shaded for the fractional part — which is what makes the edge subpixel-accurate. One strip is one second.

Installing

pip install numpy pillow scipy

ffmpeg and sox are needed only by make_print.py. It takes them from the ./sox/ folder (the Windows layout), otherwise from PATH.

Quick start

The whole thing in one command:

python make_print.py -i song.mp3 --fit 2

That takes the track, conditions the audio, lays it out on two A4 sheets with the widest strips that fit, and builds song_print/print.pdf. At the end it prints the commands for the way back, with the numbers already filled in.

Print at 100% / "Actual size", never "Fit to page". The raster is placed on the page at exactly its physical size and never scaled: the signal lives in the length of each ink bar, and any resampling smears its end.

Coming back:

python cut_strips.py -i scan.png --dry-run --pitch 74
python cut_strips.py -i scan.png -o strips --pitch 74 --rows 6590
python picky.py x 50 1 d 1 1 0.95 idp 6590 24 "strips/strip_*.tif"

Geometry: where the numbers come from

Everything hangs off two equalities:

  • one image row = one sample, so the sample rate equals the usable page height in pixels;
  • one strip = one second, so how many seconds fit on a sheet is the usable width divided by the strip width.
paper, dpi, margins rate usable width
A4 portrait, 600 dpi, 9 mm 6590 Hz 4535 px (192 mm)
A4 landscape, 600 dpi, 9 mm 4535 Hz 6590 px (279 mm)
A4 portrait, 300 dpi, 9 mm 3295 Hz 2267 px
A3 portrait, 600 dpi, 9 mm 9496 Hz 6590 px

It follows that the print scale does not matter. The printer shrank the page by 5% — the scan then has 5% fewer rows per strip, cut_strips derives a rate 5% lower, and every strip still plays for exactly one second. The pitch is right. A calibration bar on the sheet is unnecessary and has been removed; cut_strips --pitch reports the scale factor as a number instead.

The strip width is the one real trade-off: a wider strip resolves amplitude better, a narrower one fits more seconds on the page. For a 120 s track on A4 at 600 dpi:

sheets strips per sheet width amplitude
1 121 37 px = 1.6 mm ~5.2 bits
2 61 74 px = 3.1 mm ~6.2 bits
3 41 110 px = 4.7 mm ~6.8 bits

--fit N picks the widest strip that still gets the recording onto N sheets: spare page width is amplitude resolution thrown away.

The tools

The paper flags (--paper --dpi --margin --landscape --align) are the same in every tool: they are declared once, in page.py.

make_print.py — the whole outbound half

python make_print.py -i song.mp3 [-o out] [--fit [N] | --width PX] [--paper a4]
                     [--dpi 600] [--margin 9] [--landscape] [--align left]
                     [--bilevel] [--only-wav] [--rate HZ]

Three stages: ffmpeg | sox (mono, band-limited, compressed, resampled to the page height) → wav2pngsheet2pdf. It works out no geometry of its own — it asks page.py.

  • --fit N — the whole recording onto N sheets, width chosen to suit; the strips are spread evenly over the sheets.
  • --width PX — set the width by hand (default 39 px = 1.7 mm at 600 dpi).
  • --rate HZ — do not derive the rate from the page, use this one instead; needed to add sheets to a set that is already printed.
  • --bilevel — threshold to 1 bit before embedding in the PDF (never dithered: error diffusion would smear each bar end across neighbouring rows, and every row is one sample).
  • --only-wav — stop after stage 1.

wav2png.py — WAV into strips or sheets

python wav2png.py -i audio.wav [-o out] [-w PX] [--sheet N | --pages P] [paper flags]
  • without --sheet/--pages — one file per strip, width from -w (default 200);
  • --sheet N — N strips side by side on a sheet, width worked out from the paper;
  • --pages P — the whole recording onto P sheets, N follows. The same knob from the other end.

Passing --width alongside them does not recompute it, it checks it:

warning: 61 strips of 200 px need 516 mm, a4 leaves 192 mm inside the margins -- try --width 74

The last strip is padded with silence up to a full second. The last sheet is not: it stays as narrow as its strips need. Silence is a half-width bar rather than blank paper, so the boundary to cut on is always there.

sheet2pdf.py — sheets onto pages

python sheet2pdf.py -i "sheets/*.png" [-o track.pdf] [--paper a4] [--dpi 600]
                    [--align left] [--bilevel]

Every sheet becomes one page, in the order given, at exactly its physical size, embedded losslessly (Flate). Pillow's own Image.save(".pdf") will not do: it re-encodes greyscale as JPEG and sizes the page to the image.

Physical size comes from the file's own dpi tag, or from --dpi. A sheet larger than the paper is refused rather than quietly shrunk.

--align decides where a short last sheet sits relative to the full ones: left (the default) puts its left edge at the same x as the others — every page then has its strips in the same place, and one crop template fits the whole set. center centres it on its own page, right lines it up on the right edge. The reference is the widest sheet in the set, so a set of equal sheets sits exactly where it always did whichever setting is used.

cut_strips.py — a scan back into strips

python cut_strips.py -i scan.png [-o strips] [--pitch PX] [--rows N] [--dry-run]
                     [-n N] [-s 1] [--no-flush-left] [--no-skew] [--dark 128] [--trim 20]

-i takes files, folders and glob patterns (expanded internally, so "sheets/*.png" works in cmd.exe too). With several sheets each gets its own subfolder. Writes strip_000.tif by default.

  • --pitch PX — the strip spacing, as the outbound side printed it. It removes the need to measure: a sheet holding a single strip can be cut (one bar gives nothing to measure a spacing from), and strips too pale for the detector are recovered. It reports the scale factor: detected 78.18 px, given 78 -> scale 1.002.
  • --rows N — pin every strip to N rows. Strongly recommended for generated sheets: without it the strip heights wander by a fraction of a percent (each one trims its own margins), and sheets trimmed differently produce a step in tempo at the page joins. An over-crop into the print then becomes a loud error instead of a silent loss of samples.
  • --dry-run — the full measurement (strip count, pitch, height, skew, trim, rate) with no files written.
  • -n N — force the strip count, when one of them is too pale to detect.
  • -s — strips per second of the original; affects only the printed rate.
  • --no-flush-left — cut down the middle of each gap instead of on the first ink column. Right for a trace floating inside its strip; not for bars drawn from the left edge.
  • --no-skew — do not follow the drift. A sheet on the scanner glass is never square to the glass, so a strip walks sideways by a few pixels down the page; by default each strip is therefore cut along a fitted line rather than as an upright rectangle.

picky.py — strips into sound

A Python port of Patrick Feaster's Picture Kymophone 1.0 (picky.m). The arguments are positional, all optional, but must be given in order:

python picky.py w 0 1 d 1 1 0.95 all 44100 24 "strips/strip_*.tif"
                │ │ │ │ │ │  │    │    │   └ WAV bit depth (16/24/32)
                │ │ │ │ │ │  │    │    └ sample rate
                │ │ │ │ │ │  │    └ transduction type
                │ │ │ │ │ │  └ normalisation level
                │ │ │ │ │ └ impulse rejection threshold
                │ │ │ │ └ slope and DC offset adjustments
                │ │ │ └ precision: d or s
                │ │ └ power applied to pixel intensity
                │ └ processing mode
                └ output type

Help for each: python picky.py o (output), p (modes), t (transduction), a (adjustments), h (all of it).

This pipeline wants idp — intensity displacement, brightness as it stands: positive deflection = shorter bar = brighter row, so the polarity survives. Mode 50 (batch, each image independently) with output x gives a concatenated WAV plus a reference file with clicks at the join points.

picky itself is wider than this pipeline. Each column of the image (tall images it rotates itself) yields two numbers: the intensity-weighted centroid of the ink — where the trace runs — and the total intensity of the column. That covers both families of optical recording: variable-area and variable-density tracks are read by intensity (idp/ivl), while a drawn curve is read by position (pdp/pvl) — phonautograms, oscillograms, seismograms, electrocardiograms, any line recorded onto paper. The pvl/ivl variants take the derivative instead, for media that recorded velocity rather than displacement. Batch modes 51 and 52 bring a whole set of images to one amplitude range, and outputs v/b/m write vectors and matrices as .mat: an image can give up its data, not only its sound.

Saved amplitude ranges live in picky_range.json (the original kept them in wdif/sdif/tdif/ndif.mat) and are used by modes 1, 2, 3, 51 and 52.

page.py — paper geometry

Not a CLI. The one place that knows what a sheet of paper is: PAPERS (a3/a4/a5/letter/legal), parsing of 210x297, usable(), geometry(), fit(), box(), and the declaration of the shared flags. A copy of A4 used to sit in three files, and they drifted apart.

Printing and scanning in practice

Printing. 100% / "Actual size". Drivers like to default to "Fit to page" — it is the one thing here that genuinely breaks, and it breaks silently.

Scanning. At the same dpi you printed at, where you can: --pitch and --rows are pixel counts, so at another resolution both scale by the same ratio. --dry-run will show you the factor if they do not line up.

Editing the scan. Dark bands left by the scanner at the top and bottom should be removed: cut_strips takes the bounds of the print from the first and last row holding ink, and its column classification averages over the full frame height — a band across the full width raises the average of every column at once and can merge the strips into a single block.

  • paint them white rather than cropping — the frame geometry then does not change at all;
  • top and bottom are free, but not a pixel off the left or right: the left ink edge of the first strip is what every other boundary is measured from;
  • leftovers from imperfect painting are picked up by --trim.

Self-checks

Every module carries its own check, with no framework:

python page.py
python cut_strips.py --demo
python wav2png.py --demo
python sheet2pdf.py --demo
python make_print.py --demo
python test_picky.py

make_print --demo runs a synthetic track through all three stages if ffmpeg and sox are around, and otherwise checks the geometry alone and says so. sheet2pdf --demo additionally cross-checks with pypdf when it is installed.

What else is in the repository

  • picky.m — the Octave/MATLAB original, to check behaviour against.

https://griffonagedotcom.wordpress.com/2016/11/20/new-software-for-playing-pictures-of-sound-waves/

Licence

picky.py and picky.m are Picture Kymophone, Copyright (C) 2016 Patrick Feaster. Free software for any purpose provided the copyright notice is maintained, with no warranty of any kind.

About

Write sound onto a sheet of paper as a variable-area optical soundtrack, print it, scan it, and get a WAV back.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Packages

Contributors

Languages