<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DeskFox.Ai</title>
    <description>The latest articles on DEV Community by DeskFox.Ai (@deskfox).</description>
    <link>https://dev.to/deskfox</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202031%2Fa7a6d3bb-fda0-419c-afc3-cd9bc2ab190c.png</url>
      <title>DEV Community: DeskFox.Ai</title>
      <link>https://dev.to/deskfox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deskfox"/>
    <language>en</language>
    <item>
      <title>On-Device Video Captions with SpeechAnalyzer on iOS 26: What Worked and What Didn't</title>
      <dc:creator>DeskFox.Ai</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:22:57 +0000</pubDate>
      <link>https://dev.to/deskfox/on-device-video-captions-with-speechanalyzer-on-ios-26-what-worked-and-what-didnt-2g65</link>
      <guid>https://dev.to/deskfox/on-device-video-captions-with-speechanalyzer-on-ios-26-what-worked-and-what-didnt-2g65</guid>
      <description>&lt;p&gt;We build &lt;a href="https://deskfox.ai/foxtake/" rel="noopener noreferrer"&gt;FoxTake&lt;/a&gt;, an iPhone app for recording talking-head videos with a teleprompter. After you record, it cuts retakes and burns captions into the video. Version 2.0 targets iOS 26, so captions use Apple's new &lt;code&gt;SpeechAnalyzer&lt;/code&gt; / &lt;code&gt;SpeechTranscriber&lt;/code&gt; APIs and are transcribed on the phone.&lt;/p&gt;

&lt;p&gt;This post covers what we learned getting from a recorded &lt;code&gt;.mov&lt;/code&gt; to captions that look right on TikTok, Reels and Shorts. Some of it is in the docs. Most of it we found by testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device
&lt;/h2&gt;

&lt;p&gt;Our users mostly record short clips: one take of 30 seconds to 3 minutes. For that size, on-device transcription is quick, works without a network connection, and costs nothing per minute. We tested the iOS 26 transcriber against Whisper &lt;code&gt;small&lt;/code&gt; on a 46.8-second outdoor Mandarin clip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed.&lt;/strong&gt; On an iPhone with an A14 chip, &lt;code&gt;SpeechTranscriber&lt;/code&gt; finished in &lt;strong&gt;0.86 s&lt;/strong&gt;, about 54× real time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy.&lt;/strong&gt; Whisper &lt;code&gt;small&lt;/code&gt; made one word error and dropped two filler particles. The on-device transcriber got all three right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is one clip, not a benchmark, but it was enough for us to make on-device the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the transcriber
&lt;/h2&gt;

&lt;p&gt;The module setup is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;transcriptionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="nv"&gt;reportingOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="nv"&gt;attributeOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audioTimeRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcriptionConfidence&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.audioTimeRange&lt;/code&gt; is the important one. Every run in the result's &lt;code&gt;AttributedString&lt;/code&gt; carries its start and end time. That gives you word-level timing (character-level for Chinese and Japanese), which is what captions need.&lt;/p&gt;

&lt;p&gt;Things we tried that did not help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Passing the script as &lt;code&gt;contextualStrings&lt;/code&gt;.&lt;/strong&gt; Our users have a script, so biasing the recognizer toward it seemed obvious. In our tests the output was &lt;em&gt;identical&lt;/em&gt; with and without it, so we dropped it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DictationTranscriber&lt;/code&gt; for punctuation.&lt;/strong&gt; It returns punctuated text, which looks attractive for captions. On the same clip it was slower (2.74 s vs 0.86 s) and made more mistakes: it dropped words and swapped one for a similar-sounding word. We take punctuation from the user's script instead (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The old &lt;code&gt;SFSpeechRecognizer&lt;/code&gt; with on-device forced.&lt;/strong&gt; For our Chinese test it returned empty text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Language assets: online once, then offline
&lt;/h2&gt;

&lt;p&gt;Recognition is offline, but the language model may need a one-time download. Check before you start, and show progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeTranscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forModules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;observation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(\&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fractionCompleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="nf"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fractionCompleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;SpeechTranscriber.supportedLocale(equivalentTo:)&lt;/code&gt; to map the user's language to a supported locale. Don't compare identifiers by hand: &lt;code&gt;zh-Hant-TW&lt;/code&gt; vs &lt;code&gt;zh-TW&lt;/code&gt; will catch you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feeding a recorded file, not the microphone
&lt;/h2&gt;

&lt;p&gt;Most examples stream microphone audio. We transcribe a finished recording, so we read the audio track with &lt;code&gt;AVAssetReader&lt;/code&gt; and push buffers into the analyzer ourselves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask for the format the analyzer wants: &lt;code&gt;SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decode the audio track to mono Float32 PCM at that sample rate.&lt;/li&gt;
&lt;li&gt;If the analyzer's format still differs, for example Int16, convert each buffer with &lt;code&gt;AVAudioConverter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Yield each buffer into an &lt;code&gt;AsyncStream&amp;lt;AnalyzerInput&amp;gt;&lt;/code&gt; that you passed to &lt;code&gt;analyzer.start(inputSequence:)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When the reader is done, call &lt;code&gt;cont.finish()&lt;/code&gt; and then &lt;code&gt;try await analyzer.finalizeAndFinishThroughEndOfInput()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Start collecting &lt;code&gt;transcriber.results&lt;/code&gt; in a separate &lt;code&gt;Task&lt;/code&gt; before you feed any audio, or you can miss results. Each result is roughly one phrase. We keep them as segments of &lt;code&gt;(text, start, end, confidence)&lt;/code&gt; and cache them next to the recording, so re-rendering a video never transcribes again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timing: phrase starts come in early
&lt;/h2&gt;

&lt;p&gt;The raw start time of each phrase was consistently &lt;strong&gt;early&lt;/strong&gt;, by 0.49 s on average in our clip. Only 2 of 9 phrases were within 0.2 s. A caption that appears half a second before the speaker opens their mouth looks wrong.&lt;/p&gt;

&lt;p&gt;The cause: the per-word time ranges are back to back, with no gaps. After a pause, the first word absorbs the pause into its own range. In one case a single character was stamped 6.12–6.90 s, but the speaker started at 6.79 s.&lt;/p&gt;

&lt;p&gt;The fix is to distrust long first words. If a word lasts more than 0.35 s, search inside its range for where the audio energy actually rises, and start the caption there. Even a crude rule of &lt;code&gt;start = end − 0.2 s&lt;/code&gt; brought the average error to 0.13 s, with 8 of 9 phrases within 0.2 s. If your captions feel "ahead", check this before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokenizing for captions: don't lose the &lt;code&gt;%&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Caption lines are built from tokens: per character for Chinese, Japanese and Korean, per word for everything else. Two bugs we shipped and then fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symbols attached to numbers were dropped.&lt;/strong&gt; Our first tokenizer kept only letters and digits, so &lt;code&gt;5%&lt;/code&gt; became &lt;code&gt;5&lt;/code&gt; and &lt;code&gt;$20&lt;/code&gt; became &lt;code&gt;20&lt;/code&gt;. The fix is a small allow-list of symbols that belong to a word (&lt;code&gt;% ° $ € £ ¥ + &amp;amp; @ # /&lt;/code&gt;). We also keep &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt; when they sit between two digits, so &lt;code&gt;3.5&lt;/code&gt;, &lt;code&gt;1,000&lt;/code&gt; and &lt;code&gt;10:30&lt;/code&gt; survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traditional vs Simplified Chinese.&lt;/strong&gt; A user's script may be in Traditional characters while the recognizer outputs Simplified, or the other way round. For comparison we normalize each character with &lt;code&gt;StringTransform("Hant-Hans")&lt;/code&gt; (cached, since it is called per character). For display we keep the script's form, so a Traditional script gets Traditional captions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Punctuation from the script also drives line breaks. A full stop always ends a caption. A comma is a preferred place to break. In Chinese scripts people often use a space instead of a comma, so we treat a space between CJK characters as a soft break too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering: fonts, line breaks, safe areas
&lt;/h2&gt;

&lt;p&gt;We draw captions with Core Text into a &lt;code&gt;CIImage&lt;/code&gt; and composite them over the frames during export. Three details made a visible difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the font by language.&lt;/strong&gt; PingFang SC is a fine default for Chinese. For Japanese, though, it uses Chinese glyph shapes, which Japanese viewers notice right away. We switch to Hiragino Sans for &lt;code&gt;ja&lt;/code&gt;, Apple SD Gothic Neo for &lt;code&gt;ko&lt;/code&gt;, and PingFang TC or HK for Traditional Chinese. Everything else uses the system font.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Break lines at word boundaries, even without spaces.&lt;/strong&gt; For Latin text you break at spaces. Chinese and Japanese have no spaces, so we use &lt;code&gt;NLTokenizer(unit: .word)&lt;/code&gt; with the dominant language from &lt;code&gt;NLLanguageRecognizer&lt;/code&gt; to find word boundaries. Without it you get lines split in the middle of a word. If a line doesn't fit, we try every candidate break and pick the one that makes the two lines most equal in width. If two lines still don't fit, we shrink the font instead of adding a third line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't use &lt;code&gt;kCTStrokeWidth&lt;/code&gt; for outlines.&lt;/strong&gt; It strokes along the glyph contours, and where strokes cross you get visible artifacts. We render the text once, dilate it with &lt;code&gt;CIMorphologyMaximum&lt;/code&gt;, turn the result black with &lt;code&gt;CIColorMatrix&lt;/code&gt;, and put the original text on top. The result is a clean, even outline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Place captions per platform.&lt;/strong&gt; Our first version put captions 22% from the bottom, right where TikTok, Reels and Shorts overlay their own captions and buttons. Now each export target has a preset. The default sits about 60% from the top, inside a horizontal safe area of 120–888 px on a 1080×1920 frame, which is the intersection of the three platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell someone starting today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Request &lt;code&gt;.audioTimeRange&lt;/code&gt; and build everything on the timed runs.&lt;/li&gt;
&lt;li&gt;Measure on your own recordings before adding features: &lt;code&gt;contextualStrings&lt;/code&gt; and &lt;code&gt;DictationTranscriber&lt;/code&gt; looked useful and weren't, for us.&lt;/li&gt;
&lt;li&gt;Check phrase start times against the audio. They may be early.&lt;/li&gt;
&lt;li&gt;Test captions with real numbers, currencies and mixed scripts, not just plain sentences.&lt;/li&gt;
&lt;li&gt;Design for the platform UI, not for the empty video frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the result, FoxTake is on the App Store for iPhone with iOS 26. There is a &lt;a href="https://deskfox.ai/foxtake/guides/review-captions" rel="noopener noreferrer"&gt;guide on reviewing and correcting captions&lt;/a&gt; on our site. Questions about &lt;code&gt;SpeechAnalyzer&lt;/code&gt; are welcome in the comments. We're still learning this API too.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>showdev</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
