We're live in beta — earn up to 12,000 credits by signing up today. Get started
Marketing

iMessage voice note

Read this to turn a real voice message from a customer into a vertical social video that looks like a screen recording of the Messages app - the note arrives, gets tapped, and plays with its transcript filling in underneath. Covers cutting the recording, censoring swearing, and rendering the video.

  • 0 installs
  • v1
  • Updated Sep 10, 2026
Written for any connector

Written and maintained by FloConnector. Install it as kept updated and your copy follows our revisions; install it as your own and it never changes unless you change it.

SKILL.md 8.8 KB

iMessage voice note

A customer sends you a voice message saying something good about you. The strongest way to use it is not a graphic with a pull quote, it is the thing itself: a screen recording of the message arriving, being tapped, and playing.

It works because there is nothing to disbelieve. The voice is real, the interface is one everybody knows, and the viewer watches it play rather than being told what it said.

This skill produces a 1080x1920 MP4: the thread open, a couple of messages already there, typing dots, the voice note landing, a tap on play, blue waveform bars greying out behind the playhead, and the transcript growing inside the bubble as they talk.

Before anything else: what you may and may not fake

The voice is real. Everything else is staging, and staging has a line.

  • Get permission, and get it for this. “Can I use your voice note” is not the same as “can I put it in an ad”. Ask for the second one.
  • Never put words in their mouth. The text bubbles around the voice note are yours to write, but they must be things that person plausibly did send, and the safe move is to show them the exact wording before it goes out. Inventing a customer saying “this saved me $40k” is fabricating a testimonial, whatever the voice note says.
  • Never edit the audio to change the meaning. Cutting a pause, a stumble or an unrelated tangent is editing. Splicing two halves of different sentences into a claim they did not make is not.
  • Do not stage the other side of a conversation that did not happen. A lead-in of “You around?” is scene-setting. A staged bubble where you ask a leading question and their real audio appears to answer it is manufacturing an endorsement.

references/consent-and-claims.md has the specific checks, including what changes when the clip is a paid ad rather than an organic post.

The pipeline

Three steps. Two commands.

# 1. Convert whatever they sent you to 16-bit mono WAV
ffmpeg -i voice-note.m4a -ac 1 -ar 16000 -sample_fmt s16 source.wav

# 2. Cut it, censor it, trim the dead air
python scripts/cut_audio.py voice-note.json

# 3. Render
node scripts/render_imessage.mjs voice-note.json --preview=1.6,3.8,12
node scripts/render_imessage.mjs voice-note.json

cut_audio.py is standard library only. render_imessage.mjs needs Node 18+, ffmpeg on PATH, and Chrome or Edge installed - no npm install.

Always --preview first. It captures just those seconds to preview/ in about five seconds, where a full render is ~700 frames and about a minute.

cut_audio.py writes the cut WAV and its waveform, then writes the results back into your spec (audio.duration, audio.file, timed transcript). Edit the spec, re-run, look. That is the loop.

The spec

One JSON file drives both scripts. assets/example.json is a complete working one; references/spec-reference.md documents every field.

The shape of it:

{
  "outputStem": "barry-voice-note",
  "statusTime": "4:12",
  "stampLine": "Today 4:11 PM",
  "contact": { "name": "Barry Millton", "avatar": "avatar.jpg" },
  "thread": [
    { "from": "them", "text": "You around?", "at": 0 },
    { "from": "me", "text": "Yeah mate, what's up", "at": 0, "delivered": true }
  ],
  "typing": { "start": 0.75 },
  "audio": {
    "source": "source.wav",
    "ranges": [{ "start": 14.92, "end": 19.22 }],
    "beeps": [{ "start": 17.82, "end": 18.10, "word": "arse" }],
    "arrive": 2.35,
    "play": 3.7,
    "transcriptText": "I was just ringing to say ..."
  }
}

arrive is when the bubble lands, play is when it gets tapped. About 1.3 seconds between them reads as a person seeing the message and pressing it. Under a second feels robotic.

Choosing the cut

The clip is the whole job. A 20 second cut of the best 20 seconds beats a 60 second cut that includes them.

  • Open on the strongest sentence you have. There is no runway on social. If the first three seconds are throat-clearing, cut them.
  • One idea per clip. If they said three good things, that is three videos.
  • End on the hardest line, not the natural end of the sentence. People trail off. Cut before the trail.
  • Cut the dead air. A phone recording can be a third silence and it reads as drag. trimSilence does this - see below.

references/cutting-the-audio.md covers this properly, including the two things most likely to embarrass you: verifying whose voice you actually cut, and timing a censor beep correctly.

Trimming dead air

Put trimSilence on a range and any pause longer than minRun is cut back to keep seconds:

{ "start": 29.14, "end": 47.60, "trimSilence": { "minRun": 0.45, "keep": 0.24 } }

On a real example this took an 18.5 second range carrying 6.1 seconds of silence down to 0.99 seconds of silence, and the clip from 23.0 to 18.3 seconds, without touching a word.

Two things it is doing that matter: it splits the range at each pause and lets the crossfade blend two near-silent tails, which is why it never clicks; and it leaves short pauses alone, because those are the person’s cadence, not dead air. If it sounds clipped, raise keep. It is one number.

Censoring

Customers swear when they are enthusiastic. Beep it rather than lose the take.

"beeps": [{ "start": 17.82, "end": 18.10, "word": "arse" }]

Times are in the source file and are mapped through the edit, so you can retime a range or trim silence out of it without recomputing them.

Three rules, each of which cost something to learn:

  1. Time the beep from the waveform, not from a transcript. Speech-to-text word boundaries stretch across pauses. On a real clip a transcript reported a swear as running 43.78 to 44.98; the word was actually 44.66 to 45.00, 340 ms, and the rest was silence. Beeping the transcript’s window produced a 1.2 second beep where 0.34 was needed.
  2. The tone replaces the samples, it does not mix over them. cut_audio.py does this. Anything that mixes leaves the word audible underneath.
  3. Mask it in the transcript the way iOS does, a**e and f***ing, written straight into transcriptText. Do not invent a graphic treatment. On this surface the asterisks are the authentic detail.

Check your work by listening to the beep window in isolation. Do not trust a transcript of the censored file to tell you whether it worked - speech-to-text reconstructs the swear from the surrounding words and will print it whether or not it is there.

The transcript

Put the spoken words in transcriptText and cut_audio.py times them for you by spreading them across the clip in proportion to speech energy. It lands within about half a second, which is right for text appearing and wrong for highlighting a specific word - so when timings are estimated the template leaves the current-word emphasis off.

If you want the emphasis, do a real word-level pass and supply audio.transcript as [{ "w": "word", "t": 0.28 }] yourself. references/cutting-the-audio.md says how.

Leave transcriptText out entirely and the bubble is waveform only, which is also a real iOS state.

What makes it look real

The details people notice without being able to name. references/ios-anatomy.md has the full list; these are the ones that get faked wrong most often:

  • The bars grey out ahead of the playhead, they do not fill from empty. An unplayed voice note is solid blue.
  • “Raise to listen” sits under an unplayed note. “Keep” replaces it once it has been played - because audio messages expire, and that link is how you stop it.
  • Only the last bubble in a run from one side gets a tail. Three messages in a row from them is two tailless bubbles and one with a tail.
  • The transcript is inside the same grey bubble, under the waveform, not below it as a caption.
  • Bars are all exactly the same width. This is the one that will bite you: the phone is drawn at iPhone points and captured at 2x, so a bar sized in points lands on fractional pixels and rasterises unevenly. The template draws the strip as SVG in device-pixel units for exactly this reason. Do not “simplify” it back to a flex row.

Requirements

NeedWhy
ffmpeg on PATHConverting the source, encoding the video
Python 3.9+cut_audio.py, standard library only
Node 18+render_imessage.mjs, no packages
Chrome or EdgeHeadless, renders the frames
A square-ish headshotThe contact photo. CSS masks it to a circle

For the headshot: crop it centred on the face, brow line to chin, not on the whole head. Centring on the head leaves the face riding high with a wedge of neck under it, which is obvious once it is masked to a circle. Or leave the photo alone and nudge it with contact.avatarPosition ("50% 30%"), which is a CSS object-position and needs no image editor.

Reference files

Everything the skill tells your AI to read, exactly as it ships in the zip.

references/consent-and-claims.md 3.6 KB
# Consent and claims

This format is persuasive because it looks like unedited evidence. That is exactly why it has to survive being checked.

Everything here is about not misleading a viewer. It is not legal advice, and advertising rules differ by country - if the clip is a paid ad, check your local regulator's testimonial guidance.

## Get permission for the actual use

"Can I use your voice note" is not the same question as "can I put your voice and face in an ad". Ask the second one, and be specific:

- **Where it runs.** Organic social, paid ads, the website, a sales deck
- **What appears.** Their voice, their name, their business name, their photo
- **How long for**, and how they withdraw it later

Get it in writing. A message saying "yeah all good, use it" against a description of what you are actually making is enough for most purposes and takes one message.

If they are an employee of the customer rather than the owner, check who can actually agree to their employer's name being used.

## The line between staging and fabrication

The voice is real. The scene around it is built. That is fine, and it stops being fine at a predictable point.

**Fine:**

- Lead-in bubbles that set the scene, in wording the person plausibly sent
- Choosing which 20 seconds of a 4 minute message to use
- Cutting pauses, stumbles, dead air and unrelated tangents
- Censoring swearing
- A plausible timestamp and a plausible thread

**Not fine:**

- Writing a bubble in their name making a claim they never made
- Splicing two separate sentences into a claim they did not say
- Staging your own leading question so their real audio appears to answer it
- Implying a conversation that did not happen
- Using a stock photo as their contact picture

The test that catches most of it: **would this person, watching the finished clip, recognise it as what happened?** If they would say "I didn't say that" or "that's not what I meant", it is over the line regardless of the audio being genuine.

The safest version of the lead-in is text they actually sent. Second safest is text you show them before publishing. Anything else, do not.

## Claims inside the audio

If the customer makes a specific factual claim - a number, a timeframe, a saving - you are now advertising that claim, not just their opinion.

- **Can you substantiate it?** If they say it saved them ten hours a week, could you show that if asked
- **Is it typical?** A genuine but unusual result usually needs a note saying so
- **Is it still true?** A testimonial about a feature you have since changed is stale

The simplest fix for an outlier is to use a different clip. A modest claim you can stand behind is worth more than a big one you cannot.

## Things people forget

- **Other people's names.** If they name a staff member, a competitor, or another customer, cut it or get that person's agreement too
- **Their business's confidential detail.** Revenue, client names, pricing - they said it casually in a message, not for publication
- **Background audio.** Someone else's voice in the room is another person to have permission from
- **The photo.** A screenshot from a podcast is that podcast's footage. Ask them for a headshot, or get agreement from whoever owns the frame
- **Anything on the photo.** Slogans, logos and political messages on clothing come along with the crop and will be read as your positioning. At avatar size a red cap is a red cap, whatever it actually says

## Withdrawal

People change their mind, and businesses get sold. Agree up front that if they ask, you take it down - and keep the source files and the permission message together so you can find them later.
references/cutting-the-audio.md 6.4 KB
# Cutting the audio

The clip decides whether the video works. Everything else is presentation.

## Find the moments before you cut anything

Transcribe the whole recording with timestamps and read it. A 40 minute call might hold four usable clips and you will not find them by scrubbing.

Any word-level transcriber works. `faster-whisper` is the usual choice:

```python
from faster_whisper import WhisperModel
model = WhisperModel("small.en", device="cpu", compute_type="int8")
segments, _ = model.transcribe("source.wav", word_timestamps=True)
for s in segments:
    print(f"[{s.start:.2f}-{s.end:.2f}] {s.text}")
```

Then mark candidates by timestamp and cut them into ranges.

## Verify whose voice you cut

**This is the one that will embarrass you.** On a two-way call recording, the best-sounding lines are often the wrong person - you, agreeing enthusiastically with your own customer.

On one real 42 minute call, two of the four strongest quotes turned out to be the seller's voice, not the customer's. Both would have shipped without a check.

Content is usually enough to tell: whoever says "my team", "our jobs", "I pay for" is the customer. When it is genuinely ambiguous - a short exclamation, an echo, a "yeah exactly" - either listen to it directly or drop it. Never ship a line you cannot attribute.

If you have many segments to sort, cluster them acoustically: take a handful of segments whose speaker is unambiguous from content as anchors for each person, compute mean MFCCs per segment, and assign each remaining segment to the nearer anchor centroid. On an 8 kHz phone recording with two similar male voices this got 12 of 13 known-speaker lines right - good enough to triage, not good enough to publish without checking the ones you actually use.

## Ranges and joins

```json
"ranges": [
  { "start": 14.92, "end": 19.22 },
  { "start": 29.14, "end": 47.60, "gap": 0.28 }
]
```

Ranges are concatenated in order. By default consecutive ranges **crossfade** over `crossfade` seconds (0.12 default), which hides the join mid-sentence. A `gap` inserts that many seconds of silence instead, which is what you want between two separate sentences - it reads as a breath rather than a splice.

**Cut on silence, not on the word boundary.** Scan for a quiet trough near where you want the cut and put it there. A range that starts 120 ms before the first word opens with a breath; one that starts exactly on the word opens with a click.

A quick way to find the trough:

```python
import wave, array, math
w = wave.open("source.wav","rb"); rate = w.getframerate()
pcm = array.array("h"); pcm.frombytes(w.readframes(w.getnframes()))
t = 14.3
while t < 15.5:
    seg = pcm[round(t*rate):round((t+0.02)*rate)]
    rms = math.sqrt(sum(x*x for x in seg)/len(seg))/32768
    print(f"{t:6.2f} {rms:.4f} {'#'*int(rms*400)}")
    t += 0.02
```

Speech is usually above 0.02, room tone below 0.006, and true silence on a phone line is around 0.0004 because of silence suppression - which is also why inserting digital silence is undetectable on this kind of recording.

**Trust the waveform over the transcript for boundaries.** Speech-to-text word timings stretch across pauses: a word reported as 43.78 to 44.98 was really 44.66 to 45.00, with 0.84 s of silence in front of it. That is a 3x error, and it matters for both cuts and beeps.

## Trimming dead air

```json
{ "start": 29.14, "end": 47.60, "trimSilence": { "minRun": 0.45, "keep": 0.24 } }
```

Any pause longer than `minRun` is cut back to `keep`. The range is split at each one, and because both sides of the join are near-silent the crossfade blends them inaudibly.

Short pauses are deliberately left alone. They are cadence, and removing them makes someone sound like they are being chased.

Tuning:

- **Sounds clipped or breathless** - raise `keep` to 0.3 or 0.35
- **Still drags** - lower `minRun` to 0.35 so more pauses qualify
- **Room tone is loud and nothing is being detected** - raise `threshold` above 0.006

Fillers are a separate decision. `trimSilence` tightens the silence *around* an "um" but leaves the "um" itself, because it is their voice. Cutting fillers means splitting the range around each one by hand. Worth it for an ad, usually not for an organic post - a couple of "ums" is part of why it reads as real.

## Timing a censor beep

The beep window is in **source** time and is mapped through the edit, so trimming silence out of a range does not invalidate it.

Get the window from the waveform:

1. Print the RMS scan across the region, as above
2. Find the speech island - a contiguous run above ~0.01 surrounded by quiet
3. That island is the word. Use its edges

`cut_audio.py` pads 45 ms in front and 70 ms behind by default, which covers a plosive that starts before the transcript thinks it does. Override per beep with `padIn` / `padOut` when a word runs straight into the next one:

```json
{ "start": 1706.20, "end": 1706.70, "word": "fucking", "padOut": 0.012 }
```

That case is real: "fucking oath" is one breath, with "oath" starting 40 ms after "fucking" ends. Even at 12 ms of tail padding the beep mostly ate the answer. Sometimes the honest outcome is that a phrase cannot be cleanly censored - either accept a bare beep, or start the clip after it.

### Checking a beep worked

**Do not transcribe the censored file to check.** Speech-to-text reconstructs profanity from surrounding context and will print the word whether or not the audio contains it - and it guesses wrong often enough ("Holy s***" for "Holy fuck") to prove it is inferring rather than hearing.

Check two ways instead:

- **Measure the beep window.** `cut_audio.py` overwrites those samples with a pure tone, so the window's RMS should be exactly `0.16 / sqrt(2) = 0.113`. Anything else means it is mixing, not replacing
- **Listen to the window plus a little either side.** That is the only way to know the padding covered the whole word

## Getting real word timings for the transcript

The energy-weighted estimate in `cut_audio.py` is within about half a second. If you want the current-word emphasis, transcribe the **cut** WAV (not the source) and supply the timings yourself:

```python
segments, _ = model.transcribe("barry-voice-note.wav", word_timestamps=True)
words = [{"w": w.word.strip(), "t": round(w.start, 2)} for s in segments for w in s.words]
```

Put that array in `audio.transcript` and delete `audio.transcriptEstimated`. Fix the transcriber's spelling of names and products by hand, and re-apply your profanity masking - it will not match the convention.
references/ios-anatomy.md 4.8 KB
# What the Messages screen actually looks like

Checked against real iOS screenshots of a received audio message, not from memory. The template in `assets/imessage.html` implements all of this; this file is here so you can tell whether a change you are about to make is a correction or a mistake.

## The audio bubble

Left to right inside a grey received bubble:

| Part | Detail |
|---|---|
| Play button | Filled blue circle, ~28pt, white triangle. Becomes two white bars while playing |
| Waveform | Thin blue vertical bars, rounded, centred on a midline, ~3px wide on a 6px pitch at 2x |
| Duration | Grey, right aligned, `MM:SS`, tabular figures so it does not jitter as it counts |
| Transcript | Inside the same bubble, below the waveform, grey, slightly smaller than message text |

Under the bubble, outside it: **"Raise to listen"** in small grey before it has been played, replaced by **"Keep"** in blue after. That swap is the tell that someone actually listened. Audio messages expire by default, and "Keep" is how you stop that - which is why it only appears once it has been heard.

### The playhead

Bars **grey out ahead of the playhead**. The whole waveform is blue when the note is unplayed; as it plays, the bars in front of the position turn grey and the ones behind stay blue.

This trips people up because it is the opposite of a progress bar filling up. Getting it backwards is the single most obvious fake.

The duration display counts **up from 00:00** while playing, and shows the **total** when stopped.

## Bubbles and tails

- Received: `#E9E9EB` grey, black text. Sent: blue, white text, slightly deeper at the bottom than the top
- Corner radius 18pt, message text 17pt
- **Only the last bubble in a consecutive run from one side gets a tail.** Three from them in a row is two tailless and one tailed
- A tail is a filled wedge on the bubble, cut back by a page-coloured circle. Two pseudo-elements, not an SVG
- **"Delivered"** appears under the last sent message only, small and grey

## The rest of the screen

- **Status bar**: time left, dynamic island centred, signal / wifi / battery right
- **Nav bar**: back chevron left, contact photo above their name centred, FaceTime camera right. The name has a small grey chevron after it
- **Timestamp block**: centred, grey, `iMessage` in semibold on the first line and the date on the second
- **Composer**: grey `+` circle, rounded outlined field reading `iMessage`, mic glyph inside it on the right
- **Home indicator**: black bar, centred, bottom

## Motion

- Bubbles arrive with a scale-up and a slight overshoot, ~0.36s, and the thread slides up to make room at the same time
- Typing dots: three grey dots in a tailed bubble, each rising and brightening on a staggered sine
- A tap shows a soft grey ring at the touch point, growing and fading over ~0.45s

The touch ring is not something iOS draws - screen recordings only show it if the recorder turned on touch indication. It reads as authentic because that is a thing people do when demonstrating something, and without it a play button that starts playing by itself looks wrong.

## The waveform bars must be identical widths

This is the one non-obvious rendering trap and it is worth understanding before you touch the template.

The page draws the phone at iPhone logical size (393pt wide) and zooms it to fill the capture canvas, which is then captured at 2x. That makes one point equal 2.748 device pixels. A bar specified as `width: 1.25pt` in a flex row is therefore 3.44 device pixels wide, positioned at fractional offsets - so one bar rasterises as 3px, the next as 4px, the next smeared across both. They are uneven **by construction**, no matter what width you pick.

The fix is to draw the strip as SVG with a viewBox in **device pixels**, place bars at exact whole-pixel positions on a whole-pixel pitch, and set `shape-rendering="crispEdges"`:

```
DPP     = zoom * captureScale        // 1.374 * 2 = 2.748
stripW  = 184 * DPP                  // the strip's width in device pixels
barW    = 3, pitch = 6               // whole pixels, so every bar is identical
```

Verified on a rendered frame by counting run-lengths along the midline: 83 bars at exactly 3px with 3px gaps, zero variation.

Bar heights are kept even numbers so each bar is symmetric about the midline.

## Bar heights come from the audio, and need their dynamics back

`cut_audio.py` writes a waveform file of one level per frame, flattened with a `^0.58` curve. That flattening is deliberate for meters that need to keep moving during quiet speech, and it is wrong here - it produces a picket fence where every bar is the same mid height.

The template raises the levels to `^2.6` to put the dynamics back, so pauses collapse to dots and loud words spike. That is what a real Messages waveform looks like: mostly low with clear peaks, not an even comb.

If your waveform looks like a hairbrush, that exponent is why.
references/spec-reference.md 4.8 KB
# Spec reference

One JSON file drives both scripts. `assets/example.json` is a complete working one.

Paths inside it are relative to the spec file itself.

## Top level

| Field | Required | What it does |
|---|---|---|
| `outputStem` | yes | Names the outputs: `<stem>.wav`, `<stem>.waveform.json`, `<stem>-1080x1920-imessage.mp4` |
| `contact` | yes | Who the message is from. See below |
| `thread` | yes | Messages already in the conversation. See below |
| `audio` | yes | The voice note. See below |
| `statusTime` | no | Status bar clock. Default `9:41` |
| `stampLine` | no | Second line of the centred timestamp block, e.g. `Today 4:11 PM` |
| `typing` | no | `{ "start": 0.75 }` - when the typing dots appear. They run until the note arrives |
| `tail` | no | Seconds to hold after the audio ends. Default 1.8 |
| `duration` | no | Total video length. Computed as `audio.play + audio.duration + tail` if absent |

## `contact`

| Field | Required | What it does |
|---|---|---|
| `name` | yes | Shown under the photo in the nav bar |
| `avatar` | yes | Path to a photo. Any format a browser reads. CSS masks it to a circle |
| `avatarPosition` | no | CSS `object-position`, e.g. `"50% 30%"`. Nudges the framing without an image editor |

Crop the photo centred on the **face**, brow to chin - not on the whole head. Centring on the head leaves the face high in the circle with a wedge of neck under it.

## `thread`

An array, in order. Each entry:

| Field | Required | What it does |
|---|---|---|
| `from` | yes | `"them"` (grey, left) or `"me"` (blue, right) |
| `text` | yes | The message. Plain text |
| `at` | yes | Seconds. `0` means already on screen at the start; later means it pops in then |
| `delivered` | no | On the last `me` message, shows the grey "Delivered" line |

Read `consent-and-claims.md` before writing these. They are the part of the video that is not real.

## `audio`

You author the first group. `cut_audio.py` writes the second.

### You write

| Field | Required | What it does |
|---|---|---|
| `source` | yes | 16-bit mono WAV. Convert with `ffmpeg -i in.m4a -ac 1 -ar 16000 -sample_fmt s16 source.wav` |
| `ranges` | yes | Which bits of the source to keep, in order. See below |
| `arrive` | yes | Seconds into the video when the bubble lands |
| `play` | yes | Seconds into the video when it is tapped. ~1.3 s after `arrive` |
| `beeps` | no | Words to censor. See below |
| `crossfade` | no | Seconds of blend at a join. Default 0.12 |
| `transcriptText` | no | The spoken words, profanity masked. Omit for a waveform-only bubble |
| `transcript` | no | Your own timings, `[{ "w": "word", "t": 0.28 }]`. Suppresses the estimate |

### `ranges[]`

| Field | Required | What it does |
|---|---|---|
| `start`, `end` | yes | Seconds in the source |
| `gap` | no | Insert this many seconds of silence before this range instead of crossfading |
| `trimSilence` | no | `{ "minRun": 0.45, "keep": 0.24, "threshold": 0.006 }` - cut this range's own dead air |

### `beeps[]`

| Field | Required | What it does |
|---|---|---|
| `start`, `end` | yes | Seconds **in the source**. Mapped through the edit automatically |
| `word` | no | Label only, for your own reference in the output |
| `padIn` | no | Seconds of tone before `start`. Default 0.045 |
| `padOut` | no | Seconds of tone after `end`. Default 0.070 |

### `cut_audio.py` writes back

| Field | What it is |
|---|---|
| `file` | The cut WAV's filename |
| `waveformFile` | The waveform JSON's filename |
| `duration` | Length of the cut, seconds |
| `beepsOut` | Where each beep landed in the cut, for checking |
| `transcript` | Timed words, if you supplied `transcriptText` and not your own `transcript` |
| `transcriptEstimated` | `true` when those timings were estimated. The template then omits the current-word emphasis |

Re-running is safe: it recomputes everything except a `transcript` you wrote yourself.

## Rendering

```bash
node scripts/render_imessage.mjs voice-note.json --preview=1.6,3.8,12
node scripts/render_imessage.mjs voice-note.json [--keep-frames]
```

`--preview` writes those seconds to `preview/` and stops. `--keep-frames` leaves the PNG sequence in the temp directory it names.

## When it fails

| Symptom | Cause |
|---|---|
| `is not 16-bit mono` | Convert the source with the ffmpeg line above |
| `Beep ... falls outside the ranges` | The beep window is not inside any range you kept. Widen the range or fix the time |
| `Page never signalled __ready` | The avatar path is wrong, or the machine has no internet for the webfont |
| `No Chrome or Edge found` | Install one, or add its path to `BROWSERS` in the render script |
| `Spec has no audio.file` | Run `cut_audio.py` first |
| Waveform looks like an even comb | See the exponent note in `ios-anatomy.md` |
| Bars are different widths | Something re-laid the strip in points. See `ios-anatomy.md` |
| Video is silent | Check `audio.play` is less than `duration` |
scripts/cut_audio.py 10.3 KB
#!/usr/bin/env python3
"""
Cut a client recording down to the clip you want, censor words, trim dead air,
and write everything the renderer needs.

    python cut_audio.py voice-note.json

Standard library only. The input must be 16-bit mono WAV; convert first with:

    ffmpeg -i whatever.m4a -ac 1 -ar 16000 -sample_fmt s16 source.wav

It reads the `audio` block of your spec, writes `<outputStem>.wav` and
`<outputStem>.waveform.json` next to it, and WRITES BACK into the spec the
fields the renderer needs: `audio.file`, `audio.waveformFile`, `audio.duration`,
`audio.beepsOut` and, if you supplied `transcriptText`, a timed
`audio.transcript`. Editing the spec and re-running is the whole loop.

Spec `audio` block:

    "audio": {
      "source": "source.wav",
      "ranges": [
        {"start": 14.92, "end": 19.22},
        {"start": 29.14, "end": 47.60, "gap": 0.28,
         "trimSilence": {"minRun": 0.45, "keep": 0.24}}
      ],
      "beeps": [{"start": 17.82, "end": 18.10, "word": "arse"}],
      "crossfade": 0.12,
      "transcriptText": "I was just ringing to keep on blowing smoke up your a**e ..."
    }

Times are always seconds in the SOURCE file. Beeps are mapped through the edit,
so you can retime a range or trim silence out of it without recomputing them.
"""

from __future__ import annotations

from array import array
import argparse
import json
import math
from pathlib import Path
import wave

BEEP_HZ = 1000.0        # the broadcast censor tone
BEEP_LEVEL = 0.16       # ~ -16 dBFS, above a phone line's speech floor
BEEP_FADE = 0.006       # 6 ms raised-cosine edges, or the tone clicks
PAD_IN, PAD_OUT = 0.045, 0.070
PEAK_TARGET = 0.89
FPS = 30                # waveform levels per second, matching the render


def load(path: Path) -> tuple[array, int]:
    with wave.open(str(path), "rb") as src:
        if (src.getnchannels(), src.getsampwidth()) != (1, 2):
            raise SystemExit(f"{path} is not 16-bit mono. See the ffmpeg line in this file's docstring.")
        pcm = array("h")
        pcm.frombytes(src.readframes(src.getnframes()))
        return pcm, src.getframerate()


def quiet_runs(pcm: array, rate: int, start: float, end: float,
               threshold: float, min_run: float) -> list[tuple[float, float]]:
    """Stretches inside [start, end] quiet enough to count as dead air."""
    step, runs, open_at, t = 0.01, [], None, start
    while t < end:
        frame = pcm[round(t * rate):round(min(t + step, end) * rate)]
        rms = math.sqrt(sum(x * x for x in frame) / max(1, len(frame))) / 32768
        if rms < threshold and open_at is None:
            open_at = t
        elif rms >= threshold and open_at is not None:
            if t - open_at >= min_run:
                runs.append((open_at, t))
            open_at = None
        t += step
    if open_at is not None and end - open_at >= min_run:
        runs.append((open_at, end))
    return runs


def expand(pcm: array, rate: int, ranges: list[dict]) -> list[dict]:
    """Split any range carrying `trimSilence` at its dead air.

    A pause longer than `minRun` is cut back so `keep` seconds survive, half on
    each side of the join. The crossfade then blends two near-silent tails,
    which is why this never clicks. Short pauses are left alone: they are the
    speaker's cadence, not dead air.
    """
    out: list[dict] = []
    for item in ranges:
        trim = item.get("trimSilence")
        if not trim:
            out.append(dict(item))
            continue
        keep = float(trim.get("keep", 0.24)) / 2
        cursor, first = item["start"], True
        for a, b in quiet_runs(pcm, rate, item["start"], item["end"],
                               float(trim.get("threshold", 0.006)),
                               float(trim.get("minRun", 0.45))):
            if a - keep <= cursor:
                continue
            out.append({"start": cursor, "end": a + keep,
                        **({"gap": item["gap"]} if first and "gap" in item else {})})
            cursor, first = b - keep, False
        out.append({"start": cursor, "end": item["end"],
                    **({"gap": item["gap"]} if first and "gap" in item else {})})
    return out


def assemble(pcm: array, rate: int, ranges: list[dict], crossfade: float) -> tuple[array, list[int]]:
    fade = round(crossfade * rate)
    joined, offsets = array("h"), []
    for index, item in enumerate(ranges):
        chunk = pcm[round(item["start"] * rate):round(item["end"] * rate)]
        gap = round(float(item.get("gap", 0.0)) * rate)
        if index == 0:
            offsets.append(0)
            joined = chunk
        elif gap > 0:
            joined.extend(array("h", [0]) * gap)
            offsets.append(len(joined))
            joined.extend(chunk)
        else:
            overlap = min(fade, len(joined), len(chunk))
            offsets.append(len(joined) - overlap)
            mixed = array("h")
            for i in range(overlap):
                alpha = i / max(1, overlap - 1)
                mixed.append(round(joined[len(joined) - overlap + i] * (1 - alpha) + chunk[i] * alpha))
            joined = joined[:len(joined) - overlap] + mixed + chunk[overlap:]
    return joined, offsets


def to_output(ranges: list[dict], offsets: list[int], rate: int, t: float) -> float | None:
    for item, offset in zip(ranges, offsets):
        if item["start"] <= t <= item["end"]:
            return offset / rate + (t - item["start"])
    return None


def normalise(samples: array) -> None:
    peak = max((abs(s) for s in samples), default=1) or 1
    gain = min(6.0, PEAK_TARGET * 32767 / peak)
    if abs(gain - 1) < 0.02:
        return
    for i, s in enumerate(samples):
        samples[i] = max(-32768, min(32767, round(s * gain)))


def beep(samples: array, rate: int, start: float, end: float, pad_in: float, pad_out: float) -> None:
    """REPLACE the samples with a tone. Mixing over the top leaves the word audible."""
    a = max(0, round((start - pad_in) * rate))
    b = min(len(samples), round((end + pad_out) * rate))
    fade, amp = max(1, round(BEEP_FADE * rate)), BEEP_LEVEL * 32767
    for i in range(b - a):
        env = 1.0
        if i < fade:
            env = 0.5 - 0.5 * math.cos(math.pi * i / fade)
        elif i > (b - a) - fade:
            env = 0.5 - 0.5 * math.cos(math.pi * (b - a - i) / fade)
        samples[a + i] = round(amp * env * math.sin(2 * math.pi * BEEP_HZ * i / rate))


def levels_for(samples: array, rate: int) -> list[float]:
    """One level per rendered frame, flattened so quiet speech still reads."""
    window = max(1, round(rate / FPS))
    raw = [math.sqrt(sum(x * x for x in samples[o:o + window]) / max(1, len(samples[o:o + window])))
           for o in range(0, len(samples), window)]
    peak = sorted(raw)[max(0, round(len(raw) * 0.97) - 1)] or 1
    return [round(min(1, (r / peak) ** 0.58), 4) for r in raw]


def time_words(text: str, levels: list[float], duration: float) -> list[dict]:
    """Spread words across the clip in proportion to speech energy.

    A real word-timestamp pass is better; references/cutting-the-audio.md says how.
    This exists so the transcript is not blocked on one: weighting by energy
    puts words where there is sound and holds them through a pause, which is
    what the eye is checking.
    """
    words = text.split()
    if not words:
        return []
    energy, total = [], 0.0
    for level in levels:
        total += level
        energy.append(total)
    # Longer words take longer to say, so spend energy in proportion to length
    # rather than one equal share per word. Measured against a real word-level
    # pass on a 18 s clip this halves the worst-case drift.
    weights = [len(w) + 1 for w in words]
    span = sum(weights)
    if total <= 0:
        return [{"w": w, "t": round(i / len(words) * duration, 2)} for i, w in enumerate(words)]
    timed, cursor, spent = [], 0, 0
    for word, weight in zip(words, weights):
        target = spent / span * total
        while cursor < len(energy) - 1 and energy[cursor] < target:
            cursor += 1
        timed.append({"w": word, "t": round(min(duration, cursor / FPS), 2)})
        spent += weight
    return timed


parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("spec", type=Path)
args = parser.parse_args()

spec = json.loads(args.spec.read_text(encoding="utf-8"))
here = args.spec.parent
audio = spec["audio"]
stem = spec["outputStem"]

pcm, rate = load(here / audio["source"])
ranges = expand(pcm, rate, audio["ranges"])
samples, offsets = assemble(pcm, rate, ranges, float(audio.get("crossfade", 0.12)))
normalise(samples)

placed = []
for item in audio.get("beeps", []):
    start = to_output(ranges, offsets, rate, item["start"])
    end = to_output(ranges, offsets, rate, item["end"])
    if start is None or end is None:
        raise SystemExit(f"Beep {item} falls outside the ranges you selected.")
    beep(samples, rate, start, end, float(item.get("padIn", PAD_IN)), float(item.get("padOut", PAD_OUT)))
    placed.append({"word": item.get("word", ""), "start": round(start, 3), "end": round(end, 3)})

wav_path = here / f"{stem}.wav"
with wave.open(str(wav_path), "wb") as out:
    out.setnchannels(1)
    out.setsampwidth(2)
    out.setframerate(rate)
    out.writeframes(samples.tobytes())

duration = len(samples) / rate
levels = levels_for(samples, rate)
(here / f"{stem}.waveform.json").write_text(json.dumps(levels), encoding="utf-8")

audio["file"] = f"{stem}.wav"
audio["waveformFile"] = f"{stem}.waveform.json"
audio["duration"] = round(duration, 2)
audio["beepsOut"] = placed
if audio.get("transcriptText") and not audio.get("transcript"):
    audio["transcript"] = time_words(audio["transcriptText"], levels, duration)
    # Estimated, not measured: within about half a second, which is fine for text
    # appearing and wrong for highlighting a word. The template reads this and
    # leaves the current-word emphasis off. Supplying your own `transcript`
    # array from a real word-timestamp pass clears it.
    audio["transcriptEstimated"] = True
args.spec.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

trimmed = len(ranges) - len(audio["ranges"])
print(f"{wav_path.name}  {duration:.2f}s  {len(placed)} censored"
      + (f"  {trimmed} silence cut(s)" if trimmed else ""))
print(f"spec updated: audio.duration={audio['duration']}"
      + (f", {len(audio['transcript'])} words timed" if audio.get("transcript") else ""))
scripts/render_imessage.mjs 8.8 KB
/**
 * Render the iMessage voice-note video from a spec.
 *
 *   node render_imessage.mjs voice-note.json [--preview=0.3,3.8,12] [--keep-frames]
 *
 * Needs Node 18+, ffmpeg on PATH, and Chrome or Edge installed. No npm install:
 * the CCP client below is the whole dependency list.
 *
 * Run cut_audio.py first — it writes the WAV, the waveform and the timings this
 * reads out of the spec. Every frame is captured from the page by calling
 * window.__frame(t), which is a pure function of time, so the pop-ins, the
 * typing dots, the waveform progress and the transcript all come off one
 * timeline and nothing can drift against the audio.
 *
 * --preview captures only the listed seconds to ./preview/ and stops. Use it:
 * a full 24-second render is ~700 frames and about 70 seconds of work.
 */

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const HERE = dirname(fileURLToPath(import.meta.url));
const FPS = 30;
const argv = process.argv.slice(2);
const specPath = argv.find((a) => !a.startsWith("--"));
const preview = argv.find((a) => a.startsWith("--preview="))?.slice(10).split(",").map(Number);
const keepFrames = argv.includes("--keep-frames");
if (!specPath) {
  console.error("Usage: node render_imessage.mjs <spec.json> [--preview=1,4,12] [--keep-frames]");
  process.exit(1);
}

const BROWSERS = [
  "C:/Program Files/Microsoft/Edge/Application/msedge.exe",
  "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
  "C:/Program Files/Google/Chrome/Application/chrome.exe",
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
  "/usr/bin/google-chrome",
  "/usr/bin/chromium",
  "/usr/bin/chromium-browser",
];

/** Minimal Chrome DevTools Protocol client — Node 18+ has a global WebSocket. */
class Cdp {
  #ws; #id = 0; #pending = new Map();
  static async connect(url) {
    const c = new Cdp();
    c.#ws = new WebSocket(url);
    await new Promise((res, rej) => {
      c.#ws.onopen = res;
      c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
    });
    c.#ws.onmessage = (ev) => {
      const msg = JSON.parse(ev.data);
      const p = c.#pending.get(msg.id);
      if (!p) return;                       // an event, not a reply
      c.#pending.delete(msg.id);
      msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
    };
    return c;
  }
  send(method, params = {}, sessionId) {
    const id = ++this.#id;
    return new Promise((resolve, reject) => {
      this.#pending.set(id, { resolve, reject });
      this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
    });
  }
  close() { this.#ws.close(); }
}

async function launch(port) {
  const exe = BROWSERS.find((p) => existsSync(p));
  if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n  ${BROWSERS.join("\n  ")}`);
  const profile = await mkdtemp(join(tmpdir(), "imessage-render-"));
  const proc = spawn(exe, [
    "--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
    "--no-first-run", "--no-default-browser-check", "--disable-extensions",
    "--hide-scrollbars", "--force-color-profile=srgb", "--allow-file-access-from-files",
    // Render every paint we ask for instead of throttling a "background" tab.
    "--disable-background-timer-throttling", "--disable-renderer-backgrounding",
    "--run-all-compositor-stages-before-draw", "about:blank",
  ]);
  proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(1); });

  const deadline = Date.now() + 20000;
  let wsUrl;
  while (Date.now() < deadline && !wsUrl) {
    try {
      const res = await fetch(`http://127.0.0.1:${port}/json/version`);
      if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
    } catch { /* not up yet */ }
    if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
  }
  if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
  const cdp = await Cdp.connect(wsUrl);
  return {
    cdp,
    async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
  };
}

function run(cmd, cmdArgs) {
  return new Promise((res, rej) => {
    const p = spawn(cmd, cmdArgs, { stdio: "inherit" });
    p.on("error", (e) => rej(new Error(`${cmd}: ${e.message}`)));
    p.on("close", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}`))));
  });
}

const spec = JSON.parse(await readFile(specPath, "utf8"));
const dir = dirname(resolve(specPath));
const template = join(HERE, "..", "assets", "imessage.html");
if (!spec.audio?.file) {
  console.error("Spec has no audio.file — run cut_audio.py first.");
  process.exit(1);
}
for (const p of [template, join(dir, spec.audio.file), join(dir, spec.audio.waveformFile), join(dir, spec.contact.avatar)]) {
  if (!existsSync(p)) { console.error(`Missing ${p}`); process.exit(1); }
}

// The template lives beside this script, so asset paths resolve against the spec.
const meta = structuredClone(spec);
meta.contact.avatar = pathToFileURL(join(dir, spec.contact.avatar)).href;
meta.audio.waveform = JSON.parse(await readFile(join(dir, spec.audio.waveformFile), "utf8"));
meta.duration ??= Number((spec.audio.play + spec.audio.duration + (spec.tail ?? 1.8)).toFixed(2));

const frames = Math.round(meta.duration * FPS);
const frameDir = await mkdtemp(join(tmpdir(), "imessage-frames-"));
const { cdp, dispose } = await launch(9337);

try {
  const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
  await cdp.send("Page.enable", {}, sessionId);
  await cdp.send("Runtime.enable", {}, sessionId);
  // DSF 1 on purpose — clip.scale at capture time does the 2x.
  await cdp.send("Emulation.setDeviceMetricsOverride",
    { width: 540, height: 960, deviceScaleFactor: 1, mobile: false }, sessionId);
  await cdp.send("Page.addScriptToEvaluateOnNewDocument",
    { source: `window.__meta = ${JSON.stringify(meta)};` }, sessionId);
  await cdp.send("Page.navigate", { url: pathToFileURL(template).href }, sessionId);

  const readyBy = Date.now() + 30000;
  for (;;) {
    const { result } = await cdp.send("Runtime.evaluate",
      { expression: "window.__ready === true ? true : (window.__error || false)", returnByValue: true }, sessionId);
    if (result?.value === true) break;
    if (Date.now() > readyBy) throw new Error(typeof result?.value === "string"
      ? `Page error: ${result.value}` : "Page never signalled __ready — the avatar or webfonts failed to load");
    await new Promise((r) => setTimeout(r, 100));
  }

  const shoot = async () => (await cdp.send("Page.captureScreenshot",
    { format: "png", captureBeyondViewport: true,
      clip: { x: 0, y: 0, width: 540, height: 960, scale: 2 } }, sessionId)).data;

  if (preview) {
    const out = join(dir, "preview");
    await mkdir(out, { recursive: true });
    for (const t of preview) {
      await cdp.send("Runtime.evaluate", { expression: `window.__frame(${t})`, awaitPromise: true }, sessionId);
      await writeFile(join(out, `frame-${t.toFixed(2)}.png`), Buffer.from(await shoot(), "base64"));
    }
    console.log(`previews -> ${out}`);
    await dispose();
    process.exit(0);
  }

  const started = Date.now();
  for (let i = 0; i < frames; i++) {
    await cdp.send("Runtime.evaluate", { expression: `window.__frame(${i / FPS})`, awaitPromise: true }, sessionId);
    await writeFile(join(frameDir, `frame-${String(i).padStart(5, "0")}.png`), Buffer.from(await shoot(), "base64"));
    if (i % FPS === 0) process.stdout.write(`captured ${String(i / FPS).padStart(2, "0")}s / ${Math.ceil(meta.duration)}s\r`);
  }
  console.log(`\ncaptured ${frames} frames in ${((Date.now() - started) / 1000).toFixed(0)}s`);
} finally {
  await dispose();
}

// The voice sits on silence until the tap: adelay places it, apad fills the
// tail, -shortest trims the pad back to the video.
const playMs = Math.round(spec.audio.play * 1000);
const output = join(dir, `${spec.outputStem}-1080x1920-imessage.mp4`);
await run("ffmpeg", [
  "-y", "-loglevel", "error", "-stats",
  "-framerate", String(FPS), "-i", join(frameDir, "frame-%05d.png"),
  "-i", join(dir, spec.audio.file),
  "-filter_complex", `[1:a]adelay=${playMs}:all=1,apad[a]`,
  "-map", "0:v", "-map", "[a]",
  "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
  "-c:a", "aac", "-b:a", "128k", "-ar", "48000",
  "-shortest", "-movflags", "+faststart", output,
]);
console.log(`\n-> ${output}`);
if (keepFrames) console.log(`Frames kept: ${frameDir}`);
else await rm(frameDir, { recursive: true, force: true }).catch(() => {});
assets/example.json 1.3 KB
{
  "outputStem": "barry-voice-note",
  "statusTime": "4:12",
  "stampLine": "Today 4:11 PM",
  "tail": 1.8,
  "contact": {
    "name": "Barry Millton",
    "avatar": "barry.jpg",
    "avatarPosition": "50% 38%"
  },
  "thread": [
    {
      "from": "them",
      "text": "You around?",
      "at": 0
    },
    {
      "from": "me",
      "text": "Yeah mate, what's up",
      "at": 0,
      "delivered": true
    }
  ],
  "typing": {
    "start": 0.75
  },
  "audio": {
    "source": "source.wav",
    "crossfade": 0.12,
    "arrive": 2.35,
    "play": 3.7,
    "ranges": [
      {
        "start": 14.92,
        "end": 19.22
      },
      {
        "start": 29.14,
        "end": 47.6,
        "gap": 0.28,
        "trimSilence": {
          "minRun": 0.45,
          "keep": 0.24
        }
      }
    ],
    "beeps": [
      {
        "start": 17.82,
        "end": 18.1,
        "word": "arse"
      },
      {
        "start": 44.66,
        "end": 45.0,
        "word": "fucking"
      }
    ],
    "transcriptText": "I was just ringing to keep on blowing smoke up your a**e about the FloConnector. Bro, the unlock of what it can do, the executive assistant agent that I've made, with all the connections that it can do, has absolutely f***ing shocked me at how accurate it is."
  }
}
assets/imessage.html 16.9 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>iMessage voice note</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
  /*
   * A screen recording of the Messages app, drawn at iPhone logical size
   * (393pt wide) and zoomed to the 540x960 CSS canvas the renderer captures at
   * 2x. Nothing here animates by itself: every visual state is a pure function
   * of the time the renderer passes to window.__frame(t), so frame N is the
   * same whether it is captured first, last, or twice.
   */
  :root {
    --blue: #0A84FF; --blue-deep: #1470F2;
    --grey-bubble: #E9E9EB; --grey-text: #8D8D93; --grey-2: #6E6E73; --hair: #C6C6C8;
    --font: "Inter", -apple-system, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; background: #000; }
  body { width: 540px; height: 960px; overflow: hidden; }
  .phone {
    width: 393px; height: 698.7px; zoom: 1.3740458;
    background: #fff; position: relative; overflow: hidden;
    font-family: var(--font); color: #000; -webkit-font-smoothing: antialiased;
  }

  /* status bar + dynamic island */
  .status { position: absolute; inset: 0 0 auto 0; height: 54px; z-index: 5; }
  .island { position: absolute; top: 11px; left: 50%; width: 126px; height: 37px; margin-left: -63px;
            background: #000; border-radius: 20px; }
  .time { position: absolute; left: 34px; top: 17px; font-weight: 600; font-size: 17px; letter-spacing: -.2px; }
  .indicators { position: absolute; right: 24px; top: 20px; display: flex; gap: 6px; align-items: center; }
  .indicators svg { display: block; }

  /* navigation bar */
  .nav { position: absolute; top: 0; left: 0; right: 0; height: 132px; z-index: 4;
         background: rgba(249,249,249,.94); border-bottom: .5px solid var(--hair); }
  .back { position: absolute; left: 12px; top: 68px; width: 24px; height: 24px; }
  .contact { position: absolute; left: 0; right: 0; top: 58px; text-align: center; }
  .contact img { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 4px;
                 object-position: var(--avatar-pos, 50% 50%); }
  .contact .name { font-size: 11px; color: #000; display: inline-flex; align-items: center; gap: 2px; }
  .contact .name svg { width: 7px; height: 11px; }
  .facetime { position: absolute; right: 18px; top: 70px; width: 26px; height: 20px; }

  /* the thread is anchored to the bottom, so new items push old ones up */
  .thread { position: absolute; left: 0; right: 0; top: 132px; bottom: 94px; overflow: hidden; }
  .items { position: absolute; left: 16px; right: 16px; bottom: 6px; display: flex; flex-direction: column; }
  .stamp { text-align: center; color: var(--grey-text); font-size: 11px; margin: 4px 0 10px; line-height: 1.35; }
  .stamp b { font-weight: 600; }
  .row { display: flex; margin-top: 2px; }
  .row.me { justify-content: flex-end; }
  .row + .row.gap { margin-top: 8px; }
  .bubble { max-width: 76%; padding: 7px 13px 8px; border-radius: 18px; font-size: 17px; line-height: 1.28;
            position: relative; letter-spacing: -.2px; }
  .them .bubble { background: var(--grey-bubble); color: #000; border-bottom-left-radius: 18px; }
  .me .bubble { background: linear-gradient(180deg, #1B8DFF 0%, var(--blue-deep) 100%); color: #fff; }
  /* iMessage tails: a filled wedge cut back by a page-coloured circle */
  .tail .bubble::before { content: ""; position: absolute; bottom: 0; width: 16px; height: 18px; }
  .them.tail .bubble::before { left: -6px; background: var(--grey-bubble);
                               border-bottom-right-radius: 14px 12px; }
  .me.tail .bubble::before { right: -6px; background: var(--blue-deep);
                             border-bottom-left-radius: 14px 12px; }
  .tail .bubble::after { content: ""; position: absolute; bottom: 0; width: 10px; height: 20px; background: #fff; }
  .them.tail .bubble::after { left: -10px; border-bottom-right-radius: 10px; }
  .me.tail .bubble::after { right: -10px; border-bottom-left-radius: 10px; }
  .delivered { text-align: right; color: var(--grey-text); font-size: 11px; font-weight: 500; margin: 3px 4px 0 0; }

  .typing .bubble { padding: 12px 14px; }
  .dots { display: flex; gap: 5px; }
  .dots i { width: 8px; height: 8px; border-radius: 50%; background: #9A9AA0; display: block; }

  /* audio message */
  .audio .bubble { width: 300px; max-width: none; padding: 10px 14px 10px 12px; }
  .audio .player { display: flex; align-items: center; gap: 10px; height: 30px; }
  .play { width: 28px; height: 28px; border-radius: 50%; background: var(--blue); flex: none; position: relative; }
  .play svg { position: absolute; inset: 0; margin: auto; display: block; }
  .play svg.tri { transform: translateX(1.2px); }   /* optical centre for a triangle */
  /* Drawn as SVG in device-pixel units (see barsSvg) so every bar is the same
     whole number of pixels wide; a flex row of 1.25pt bars under the zoom was
     landing on different sub-pixel offsets and rasterising unevenly. */
  .wave { width: 184px; height: 30px; flex: none; }
  .wave svg { display: block; width: 184px; height: 30px; }
  .clock { color: var(--grey-2); font-size: 13px; font-variant-numeric: tabular-nums; flex: none; min-width: 42px; text-align: right; }
  .transcript { margin-top: 8px; color: #616166; font-size: 15px; line-height: 1.3; letter-spacing: -.15px; min-height: 0; }
  .transcript:empty { display: none; }
  .transcript .now { color: #2C2C2E; }
  .under { color: var(--grey-text); font-size: 11px; margin: 4px 0 0 10px; }
  .under.keep { color: var(--blue); font-size: 13px; font-weight: 500; }

  /* pop-in for every new item, driven from __frame */
  .pop { transform-origin: 0 100%; }
  .me .pop, .row.me { transform-origin: 100% 100%; }

  /* touch ring */
  .touch { position: absolute; width: 44px; height: 44px; border-radius: 50%; margin: -22px 0 0 -22px;
           background: rgba(60,60,67,.30); border: 2px solid rgba(60,60,67,.55); pointer-events: none; z-index: 9; }

  /* composer + home indicator */
  .composer { position: absolute; left: 0; right: 0; bottom: 0; height: 94px; background: #fff; z-index: 4; }
  .plus { position: absolute; left: 16px; top: 12px; width: 34px; height: 34px; border-radius: 50%; background: var(--grey-bubble); }
  .plus::before, .plus::after { content: ""; position: absolute; background: #6E6E73; border-radius: 1px; }
  .plus::before { left: 9px; right: 9px; top: 16px; height: 2px; }
  .plus::after { top: 9px; bottom: 9px; left: 16px; width: 2px; }
  .field { position: absolute; left: 60px; right: 16px; top: 12px; height: 34px; border: 1px solid var(--hair);
           border-radius: 17px; color: #BDBDC2; font-size: 17px; padding: 6px 14px; letter-spacing: -.2px; }
  .field svg { position: absolute; right: 10px; top: 7px; }
  .home { position: absolute; left: 50%; bottom: 8px; width: 134px; height: 5px; margin-left: -67px; border-radius: 3px; background: #000; }
</style>
</head>
<body>
<div class="phone" id="phone">
  <div class="nav">
    <svg class="back" viewBox="0 0 24 24" fill="none" stroke="#0A84FF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4l-8 8 8 8"/></svg>
    <div class="contact"><img id="avatar" alt=""><div class="name"><span id="contact-name"></span><svg viewBox="0 0 7 11" fill="none" stroke="#8D8D93" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 1.5l4 4-4 4"/></svg></div></div>
    <svg class="facetime" viewBox="0 0 26 20" fill="none" stroke="#0A84FF" stroke-width="1.8" stroke-linejoin="round"><rect x="1.5" y="2.5" width="16" height="15" rx="4"/><path d="M17.5 8.5l6-3.5v10l-6-3.5"/></svg>
  </div>
  <div class="status">
    <div class="island"></div>
    <div class="time" id="status-time"></div>
    <div class="indicators">
      <svg width="18" height="12" viewBox="0 0 18 12" fill="#000"><rect x="0" y="8" width="3" height="4" rx=".8"/><rect x="5" y="5.5" width="3" height="6.5" rx=".8"/><rect x="10" y="3" width="3" height="9" rx=".8"/><rect x="15" y="0" width="3" height="12" rx=".8"/></svg>
      <svg width="16" height="12" viewBox="0 0 16 12" fill="#000"><path d="M8 11.4a1.4 1.4 0 1 0 0-2.8 1.4 1.4 0 0 0 0 2.8zM4.6 7.3l1.3 1.3a3 3 0 0 1 4.2 0l1.3-1.3a4.8 4.8 0 0 0-6.8 0zM2 4.7l1.3 1.3a6.6 6.6 0 0 1 9.4 0L14 4.7a8.4 8.4 0 0 0-12 0z"/></svg>
      <svg width="27" height="13" viewBox="0 0 27 13"><rect x=".6" y=".6" width="22.8" height="11.8" rx="3.6" fill="none" stroke="#000" stroke-opacity=".38"/><rect x="2.2" y="2.2" width="19.6" height="8.6" rx="2" fill="#000"/><path d="M25 4.6v3.8a1.9 1.9 0 0 0 0-3.8z" fill="#000" fill-opacity=".38"/></svg>
    </div>
  </div>

  <div class="thread"><div class="items" id="items"></div></div>
  <div class="touch" id="touch" hidden></div>

  <div class="composer">
    <div class="plus"></div>
    <div class="field">iMessage<svg width="16" height="20" viewBox="0 0 16 20" fill="none" stroke="#8D8D93" stroke-width="1.7" stroke-linecap="round"><rect x="5" y="1" width="6" height="11" rx="3" fill="#8D8D93" stroke="none"/><path d="M2.5 9.5a5.5 5.5 0 0 0 11 0M8 15v3.5M5 18.5h6"/></svg></div>
    <div class="home"></div>
  </div>
</div>

<script>
  const meta = window.__meta;
  const $ = (s) => document.querySelector(s);
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
  const easeOut = (x) => 1 - Math.pow(1 - x, 3);
  const spring = (x) => 1 - Math.exp(-6.5 * x) * Math.cos(9 * x); // a little overshoot, settles by x~1
  const POP = 0.36;

  $('#status-time').textContent = meta.statusTime || '9:41';
  $('#contact-name').textContent = meta.contact.name;
  const avatarImg = $('#avatar');
  avatarImg.src = meta.contact.avatar;
  if (meta.contact.avatarPosition) $('#phone').style.setProperty('--avatar-pos', meta.contact.avatarPosition);

  const audio = meta.audio;
  const words = audio.transcript || [];
  // The wave strip is 184pt wide (300 bubble - padding - play - clock - gaps).
  // One pt is 2.748 device px here (1.374 zoom x 2 capture), so the strip is
  // laid out in a viewBox of device pixels: 3px bars on a 6px pitch, the
  // density iOS draws, and every bar an identical whole number of pixels.
  const DPP = 1.3740458 * 2;
  const stripW = 184 * DPP, stripH = 30 * DPP, barW = 3, pitch = 6;
  const bars = Math.floor((stripW + (pitch - barW)) / pitch);
  // The builder's levels are flattened (^0.58) so a dial keeps moving through
  // quiet speech. Messages wants the opposite: lift the dynamics back out so
  // pauses collapse to dots and the loud words spike, like a real recording.
  const levels = audio.waveform.map((v) => Math.pow(v, 2.6));
  const barHeights = Array.from({ length: bars }, (_, i) => {
    const a = Math.floor(i / bars * levels.length), b = Math.max(a + 1, Math.floor((i + 1) / bars * levels.length));
    const peak = levels.slice(a, b).reduce((m, v) => Math.max(m, v), 0);
    return 2 + Math.round(peak * 38) * 2;   // device px, kept even so the bar is symmetric about the midline
  });

  function barsSvg(playedUpTo) {
    const rects = barHeights.map((h, i) => {
      const off = playedUpTo !== null && i / bars > playedUpTo;
      const y = Math.round((stripH - h) / 2);
      return `<rect x="${i * pitch}" y="${y}" width="${barW}" height="${h}" fill="${off ? '#BDBDC2' : '#0A84FF'}"/>`;
    }).join('');
    return `<svg viewBox="0 0 ${stripW.toFixed(3)} ${stripH.toFixed(3)}" shape-rendering="crispEdges">${rects}</svg>`;
  }

  function tailClass(items, index) {
    // iOS only draws a tail on the last bubble of a run from the same side.
    const next = items[index + 1];
    return !next || next.from !== items[index].from ? ' tail' : '';
  }

  function clockLabel(seconds) {
    const s = Math.max(0, Math.floor(seconds));
    return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
  }

  function render(t) {
    const items = $('#items');
    items.innerHTML = '';
    let shift = 0;   // how far the whole thread still has to slide up for items mid-pop
    const pops = [];

    const stamp = document.createElement('div');
    stamp.className = 'stamp';
    stamp.innerHTML = `<b>iMessage</b><br>${meta.stampLine}`;
    items.appendChild(stamp);

    const visible = meta.thread.filter((m) => m.at <= t);
    visible.forEach((m, i) => {
      const row = document.createElement('div');
      row.className = `row ${m.from}${tailClass(visible, i)}${i > 0 && visible[i - 1].from !== m.from ? ' gap' : ''}`;
      row.innerHTML = `<div class="bubble pop">${m.text}</div>`;
      items.appendChild(row);
      if (m.at > 0) pops.push({ el: row, since: t - m.at });
      if (m.from === 'me' && m.delivered && !visible.slice(i + 1).some((n) => n.from === 'me')) {
        const d = document.createElement('div'); d.className = 'delivered'; d.textContent = 'Delivered';
        items.appendChild(d);
      }
    });

    // typing indicator lives between typing.start and the audio's arrival
    const typing = meta.typing;
    if (typing && t >= typing.start && t < audio.arrive) {
      const row = document.createElement('div');
      row.className = 'row them tail typing gap';
      const phase = (t - typing.start) * 2.2;
      const dots = [0, 1, 2].map((k) => {
        const y = Math.max(0, Math.sin(phase - k * 0.9)) * -3.5;
        const o = 0.55 + Math.max(0, Math.sin(phase - k * 0.9)) * 0.45;
        return `<i style="transform:translateY(${y.toFixed(2)}px);opacity:${o.toFixed(2)}"></i>`;
      }).join('');
      row.innerHTML = `<div class="bubble pop"><div class="dots">${dots}</div></div>`;
      items.appendChild(row);
      pops.push({ el: row, since: t - typing.start });
    }

    // the voice note itself
    let playBtn = null;
    if (t >= audio.arrive) {
      const playing = t >= audio.play && t < audio.play + audio.duration;
      const done = t >= audio.play + audio.duration;
      const pos = playing ? t - audio.play : done ? audio.duration : 0;
      const frac = pos / audio.duration;
      const wave = barsSvg(playing || done ? frac : null);
      const icon = playing
        ? `<svg width="10" height="12" viewBox="0 0 10 12" fill="#fff"><rect x="0" y="0" width="3.4" height="12" rx="1"/><rect x="6.6" y="0" width="3.4" height="12" rx="1"/></svg>`
        : `<svg class="tri" width="12" height="12" viewBox="0 0 12 12" fill="#fff"><path d="M1.5 1.2v9.6c0 .9 1 1.4 1.7.9l7.3-4.8c.7-.4.7-1.4 0-1.8L3.2.3c-.7-.5-1.7 0-1.7.9z"/></svg>`;
      const spoken = words.filter((w) => w.t <= pos);
      const transcript = (playing || done) && spoken.length
        ? spoken.map((w, i) => `<span class="${playing && !audio.transcriptEstimated && i === spoken.length - 1 ? 'now' : ''}">${w.w}</span>`).join(' ')
        : '';
      const label = playing ? clockLabel(pos) : clockLabel(audio.duration);
      const under = done ? `<div class="under keep">Keep</div>` : (t >= audio.play ? '' : `<div class="under">Raise to listen</div>`);

      const row = document.createElement('div');
      row.className = 'row them tail audio gap';
      row.innerHTML = `<div class="bubble pop"><div class="player"><div class="play" id="play">${icon}</div><div class="wave">${wave}</div><div class="clock">${label}</div></div><div class="transcript">${transcript}</div></div>`;
      items.appendChild(row);
      if (under) { const u = document.createElement('div'); u.innerHTML = under; items.appendChild(u.firstChild); }
      pops.push({ el: row, since: t - audio.arrive });
      playBtn = row.querySelector('#play');
    }

    // pop-ins: the item scales up while the rest of the thread slides to make room
    for (const p of pops) {
      const x = clamp(p.since / POP, 0, 1);
      const s = 0.6 + 0.4 * spring(x);
      const bubble = p.el.querySelector('.pop');
      bubble.style.transform = `scale(${s.toFixed(3)})`;
      bubble.style.opacity = String(clamp(x * 2.5, 0, 1));
      if (x < 1) shift += p.el.offsetHeight * (1 - easeOut(x));
    }
    items.style.transform = `translateY(${shift.toFixed(2)}px)`;

    // touch ring on the play button at the moment of the tap
    const touch = $('#touch');
    const since = t - audio.play;
    if (playBtn && since >= -0.05 && since < 0.42) {
      const r = playBtn.getBoundingClientRect(), ph = $('#phone').getBoundingClientRect();
      const z = 1.3740458;
      touch.hidden = false;
      touch.style.left = `${((r.left + r.width / 2) - ph.left) / z}px`;
      touch.style.top = `${((r.top + r.height / 2) - ph.top) / z}px`;
      const x = clamp((since + 0.05) / 0.47, 0, 1);
      touch.style.transform = `scale(${(0.55 + 0.7 * easeOut(x)).toFixed(3)})`;
      touch.style.opacity = String(1 - x * x);
    } else {
      touch.hidden = true;
    }
  }

  window.__frame = (t) => { render(t); return true; };

  Promise.all([document.fonts.ready, new Promise((res) => { avatarImg.complete ? res() : (avatarImg.onload = res, avatarImg.onerror = res); })])
    .then(() => { render(0); window.__ready = true; })
    .catch((e) => { window.__error = String(e); });
</script>
</body>
</html>

Questions, answered

What does the iMessage voice note skill do?

Read this to turn a real voice message from a customer into a vertical social video that looks like a screen recording of the Messages app - the note arrives, gets tapped, and plays with its transcript filling in underneath. Covers cutting the recording, censoring swearing, and rendering the video. It is a document in the Agent Skills format: the steps, the rules and the reference files your AI reads when the job comes up.

How do I install it?

Add to FloConnector opens it inside your workspace, where Install puts it into one of your collections. Every profile carrying that collection has it on its next call. Download zip gives you the same skill as a bundle for any client that installs skills from disk.

Will it change after I install it?

Only if you ask it to. Keep updated follows FloConnector's revisions (this is v1) and records each one in the skill's history. Make my own is a copy that never changes unless you change it, and a kept-updated skill can be made editable later in one click.

Can I edit it or reuse it elsewhere?

Yes. You can copy, change, rename and redistribute it, commercially or not, with no attribution. Every skill in the library is published under CC0 1.0, and the zip carries the licence text.