DEV Community

Nikita Iakovlev
Nikita Iakovlev

Posted on

The Same Song in Two TikToks Came Back as 'Made My Night' and 'Made My Life'

I run a visa agency in Bali. The half of my week that has nothing to do with visas goes into Apify Actors, and one of them reads short vertical video: paste TikTok links, Reels, Shorts or a whole profile, get one flat row per video with the transcript, the text burned into the picture, and the first three seconds of both.

The transcript part sounds solved. Speech recognition is a commodity in 2026 — download the audio, post it to a model, get words. What nobody warns you about is that in short-form video, the words you get back are frequently not the words the creator said, and a plain transcript field cannot tell you which case you are in.

Eleven rows from one profile

13 October 2026, profile mode: @duolingo, ten newest videos, default settings, no on-screen text. Eleven rows, 87 seconds end to end.

Duration Source transcriptText
5.55 s speech-recognition .
14.63 s speech-recognition Ahem.
7.41 s speech-recognition Веритесь Майнкрафта! Ему место в Бэкрус!
17.12 s speech-recognition You just made my night, I'm getting better now…
21.57 s speech-recognition Time for typical, but what? You just made my life…
45.6 s platform-captions Boom! Boom! Boom! Boom! Boom! Can you say happy birthday…
10.54 s speech-recognition Mama, please take a picture. Yes. Thank you…

Rows carrying an actual script by the account that posted them: six of eleven. One row is a full stop, one is a throat clearing, three are lyrics from borrowed sounds, and one is Russian containing two words that are not Russian words.

That ratio is not a Duolingo quirk. On a labelled set of 26 real videos I measured earlier, only 69% carried a narrated script, 19% had no usable speech, and 12% ran on someone else's trending sound. Treat transcriptText as "what this creator said" and roughly a third of your rows lie to you — and they pass a spot check, because the rows you scroll past first are the narrated ones.

The same song, two different strings

The two lyric rows are the useful ones: both clips ride the same LE SSERAFIM track, and recognition heard it twice.

17.12 s → "You just made my night, I'm getting better now You just made my night,
           got me figured out Feelings got me high, oh baby, it's just science"

21.57 s → "Time for typical, but what? You just made my life, I feel better now
           You just made my life, got me figured out Feeling got me high
           Oh baby, it's just science"
Enter fullscreen mode Exit fullscreen mode

Same line, two spellings: made my night and made my life. The creator's own caption on the second clip reads "made my night, i'm literally an owl" — so we know which is wrong, and we know it only from metadata, never from the audio.

The practical consequence: a transcript is not a usable key for borrowed audio. Group a feed by transcriptText to find every clip riding one sound and you get two groups where there should be one. Use the platform's sound ID for that, not text.

Two sources, and the field that names them

Every row carries transcriptSource with one of two values, and it changes what the row means.

  • platform-captions — TikTok, Instagram or YouTube already had a caption track, so no video was downloaded and no recognition ran. Fast, and free of the audio charge.
  • speech-recognition — no track existed, so the audio went through Whisper large v3 turbo.

In the run above it was 1 of 11; in a run over the same account two days earlier, 3 of 11. Platform captions on TikTok are the minority case and an unstable one — whether a video has them depends on the creator's upload settings, not on anything you control.

The two sources also parse differently, which cost me a bug. YouTube's auto-captions arrive rolling: every cue repeats the previous line so the box can scroll, so without dedup the transcript is three times too long. TikTok and Instagram captions are not rolling, and there the same dedup deletes real repetition — a clip with one line said five times came back with three:

def parse_vtt(text: str, rolling: bool = True) -> dict:
    # YouTube: each cue repeats the previous line → drop the repeat.
    # TikTok / Instagram: a repeated line is a repeated line → keep it.
    ...
    if not t or (rolling and t == seen_tail):
        continue
Enter fullscreen mode Exit fullscreen mode

One flag, opposite defaults per platform. There is no way to infer it from the file.

When the platform supplies the captions, it supplies the language too

A seven-second clip captioned "proof that tacos are a language" came back like this:

{
  "durationSeconds": 7.22,
  "language": "vi",
  "transcriptSource": "platform-captions",
  "transcriptText": "ta cô ta có ta cô ta cô ta có ta cô cô ta có ta cô"
}
Enter fullscreen mode Exit fullscreen mode

That is TikTok's own caption generator hearing the English word taco repeated and filing the clip as Vietnamese, tone marks included. My Actor passes it through, because on platform-captions rows the language label is the platform's — inventing a second opinion would be worse. So language is a recognition result only on speech-recognition rows. Elsewhere it is a claim by TikTok, and TikTok is wrong often enough that you should not route anything on it.

YouTube Shorts are cheap and arrive pre-mangled

For YouTube the Actor never downloads the video. YouTube serves Shorts audio only through residential traffic, which at $8/GB costs more on a one-minute clip than I charge for the whole row — so the caption track is the only sane path: a few kilobytes of text, back in seconds, no audio charge. A Short with no track of its own gets an explicit free error row instead of a silent loss.

The catch is formatting. A 40-second MrBeast Short, verbatim:

Find a subscriber for whatever country\nthat lands on, give them 10 grand.
>> Okay, Indonesia.\n>> Hey, you got to go now.
...
>> Subscribe to Mr. [music] Beast.
Enter fullscreen mode Exit fullscreen mode

Three artifacts in one string: >> as a speaker-change marker, hard newlines wrapped at caption-box width instead of at sentence ends, and [music] injected mid-phrase — "Mr. [music] Beast". Strip newlines, markers and bracketed tags before this reaches an LLM or a search index, or your embeddings carry a lot of caption-box geometry.

What recognition says when there is nothing to say

Speech models do not stay quiet over music; they emit their most probable sentence. On wordless clips I have collected "We'll be right back.", "Thanks for watching", and on a music-only TikTok the Korean "구독과 좋아요 부탁드립니다." — "please subscribe and like". The model's own confidence is no help: on one branded clip it reported a false-positive probability of 0.00 for a sentence nobody said.

So there is a filter — short output matching a list of stock lines and music tags becomes an empty transcript, the row is marked as carrying no speech, and the audio is not billed. It works on sentences. It does not work on .: that 5.55-second row above is a silent Taco Bell promo whose transcript is one period, and speechDetected came back true because the string is not empty. Punctuation-only output is the next entry on that list. Until then, treat a transcript with no alphanumeric characters as no speech.

The guard that costs six cents

The Actor can label all of this per row — contentClass of narrated, borrowed_audio, silent or empty, plus speechCoverage and a count of burned-in subtitle tracks. Those fields fill only when on-screen text reading is enabled, because the class is decided by comparing speech against what is written in the frame — which means downloading the video and reading it frame by frame. That is a separate $0.06 event, it was off in the run above, and that is exactly why every contentClass in it is null.

So the trade is $0.004 per clip for text with no verdict, or $0.064 for text plus a verdict on whose words they are. For a one-off competitor sweep, pay it. For 50,000 rows, don't — filter yourself on word count, repeated lines and punctuation-only strings.

from apify_client import ApifyClient

client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("lergassy/tiktok-reels-shorts-transcript-scraper").call(run_input={
    "profiles": ["@duolingo"],
    "resultsLimitPerProfile": 10,
    "onlyPostsNewerThan": "2026-10-01",
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    words = len((row["transcriptText"] or "").split())
    print(row["transcriptSource"], words, row["firstThreeSecondsSpeech"])
Enter fullscreen mode Exit fullscreen mode

The Actor is here — pay-per-event, $0.002 per video plus $0.001 per started 10 seconds of recognised audio, no login, no proxy fee. Wrong tool if you already hold the media file (use a plain transcriber), if you need Instagram profiles rather than Reel links (those need a login and are not supported), or if you really just want engagement numbers — the rows carry views and likes, but a metrics scraper gets them for a fraction of the price.

One thing to take from this: in short video, log where every transcript came from. The words are the easy part.

Top comments (0)