<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged audio</title>
<link>https://coderlegion.com/tag/audio</link>
<description>Powered by Question2Answer</description>
<item>
<title>Building a Low-Latency Virtual Piano and Drum Kit on iOS with AVAudioEngine</title>
<link>https://coderlegion.com/32679/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&quot;&gt;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Samplers for pitched instruments, a player-node pool for drums, a multi-touch keyboard that handles chords and glissandos, and note names for beginners&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-icon-512.jpg&quot; alt=&quot;Drums n Piano app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;The test for a virtual instrument is brutal and immediate: tap a key, and the sound has to arrive &lt;em&gt;with&lt;/em&gt; your finger, not after it. Play a chord and you expect three notes, not one. Drag your finger along the keyboard and you expect a glissando, not silence. Hit the snare four times quickly and you expect four hits, not one hit cut off three times.&lt;/p&gt;
&lt;p&gt;My app &lt;strong&gt;Drums n Piano&lt;/strong&gt; puts a piano, a drum kit and a xylophone on your phone. The listing promises an instrument that &quot;sounds real, just like the original instrument!&quot;, and it's also meant for learning: it teaches beginners &quot;the names &amp;amp; positions of notes on the piano&quot;.&lt;/p&gt;
&lt;p&gt;This article covers how I'd build the audio and touch parts of an app like that on iOS. That means configuring the session for low latency, using &lt;code&gt;AVAudioUnitSampler&lt;/code&gt; for pitched instruments, a pool of &lt;code&gt;AVAudioPlayerNode&lt;/code&gt;s for one-shot drum samples, a custom multi-touch keyboard view, and labelling notes for learners. The app supports iOS 12 and later, so the touch layer is UIKit, and I'll explain why that's the right call even today. API names and availability are checked against Apple's documentation, and this is the approach I'd use rather than a tour of the app's source.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-three-things-a-virtual-instrument-must-get-right&quot;&gt;1. The three things a virtual instrument must get right&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Latency.&lt;/strong&gt; The gap between touch and sound. Part of it is the audio buffer, and part is your own code: loading a file on tap, or allocating while the user is playing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polyphony.&lt;/strong&gt; Several sounds at once: chords on the piano, overlapping cymbal and kick, a fast roll on the snare.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Touch handling.&lt;/strong&gt; Many fingers at once, sliding between keys, and no gesture delays.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these are hard individually. They get hard when the first prototype plays a sound file with &lt;code&gt;AVAudioPlayer&lt;/code&gt; on each tap. That loads, decodes and allocates on the main thread, can't overlap itself, and has noticeably more delay than the engine-based approaches below.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-session-setup-for-an-instrument&quot;&gt;2. Session setup for an instrument&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

enum InstrumentSession {
    static func activate() throws {
        let session = AVAudioSession.sharedInstance()
        // .playback: the instrument IS the app, so play even with the Silent switch on.
        // .mixWithOthers: let people play along with music from another app.
        try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
        try session.setPreferredIOBufferDuration(0.005)   // ask for ~5 ms; the system decides
        try session.setActive(true)
        print(&quot;IO buffer: \(session.ioBufferDuration * 1000) ms, rate: \(session.sampleRate) Hz&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two decisions here deserve a sentence each:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.playback&lt;/code&gt; vs &lt;code&gt;.ambient&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;.ambient&lt;/code&gt; respects the Silent switch, which suits a game's background music. For an instrument, silence when the switch is on looks like a broken app, so I use &lt;code&gt;.playback&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A small preferred buffer&lt;/strong&gt; reduces output latency. It's a &lt;em&gt;preference&lt;/em&gt;: read back &lt;code&gt;ioBufferDuration&lt;/code&gt; to see what you actually got, and expect Bluetooth headphones to add delay you can't remove. Wired headphones or the built-in speaker are best for playing in time.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-pitched-instruments-avaudiounitsampler&quot;&gt;3. Pitched instruments: AVAudioUnitSampler&lt;/h3&gt;
&lt;p&gt;For piano and xylophone, you don't want one audio file per tap. You want a &lt;strong&gt;sampler&lt;/strong&gt;: an instrument that receives MIDI-style &quot;note on/off&quot; messages and plays the right sample, pitch-shifting between recorded notes if needed. &lt;code&gt;AVAudioUnitSampler&lt;/code&gt; (iOS 8+) is Apple's sampler, wrapped as an engine node.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class PitchedInstrument {
    private let engine: AVAudioEngine
    private let sampler = AVAudioUnitSampler()

    init(engine: AVAudioEngine) {
        self.engine = engine
        engine.attach(sampler)
        engine.connect(sampler, to: engine.mainMixerNode, format: nil)
    }

    /// Load a SoundFont (.sf2) or DLS bank shipped in the bundle.
    func loadSoundBank(named name: String, program: UInt8 = 0) throws {
        guard let url = Bundle.main.url(forResource: name, withExtension: &quot;sf2&quot;) else {
            throw CocoaError(.fileNoSuchFile)
        }
        try sampler.loadSoundBankInstrument(
            at: url, program: program,
            bankMSB: UInt8(kAUSampler_DefaultMelodicBankMSB),
            bankLSB: UInt8(kAUSampler_DefaultBankLSB))
    }

    /// Or: load individual samples (e.g. one recording every few semitones).
    func loadSamples(_ urls: [URL]) throws {
        try sampler.loadAudioFiles(at: urls)
    }

    func noteOn(_ midi: UInt8, velocity: UInt8 = 100) {
        sampler.startNote(midi, withVelocity: velocity, onChannel: 0)
    }

    func noteOff(_ midi: UInt8) {
        sampler.stopNote(midi, onChannel: 0)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this is the right tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Polyphony is built in.&lt;/strong&gt; Ten fingers, ten notes. The sampler manages voices for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Note-off is real.&lt;/strong&gt; Releasing a key triggers the sample's release, as on a real piano, rather than a hard cut.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loading happens once&lt;/strong&gt;, at launch or when the user switches instrument, never on the tap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where do the sounds come from? Either a SoundFont you have the rights to ship, or your own recordings, with one sample every few semitones loaded via &lt;code&gt;loadAudioFiles(at:)&lt;/code&gt;. Sample quality is the single biggest factor in &quot;sounds real&quot;, and it's a licensing question as much as a technical one: check the licence of any sound bank before you bundle it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-drums-preloaded-buffers-and-a-player-node-pool&quot;&gt;4. Drums: preloaded buffers and a player-node pool&lt;/h3&gt;
&lt;p&gt;Drums are different. There's no pitch, every hit is a one-shot, and the same pad is hit repeatedly and fast. You &lt;em&gt;could&lt;/em&gt; map drums onto a sampler (General MIDI percussion is exactly that), but a simple, very controllable approach is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Decode each drum sample into an &lt;code&gt;AVAudioPCMBuffer&lt;/code&gt; &lt;strong&gt;once&lt;/strong&gt;, at launch.&lt;/li&gt;
&lt;li&gt;Keep a small &lt;strong&gt;pool&lt;/strong&gt; of &lt;code&gt;AVAudioPlayerNode&lt;/code&gt;s per pad.&lt;/li&gt;
&lt;li&gt;On each hit, schedule the buffer on the next node in the pool, round-robin.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class DrumKit {
    private let engine: AVAudioEngine
    private var buffers: [String: AVAudioPCMBuffer] = [:]
    private var pools: [String: [AVAudioPlayerNode]] = [:]
    private var nextIndex: [String: Int] = [:]
    private let voicesPerPad = 4     // how many overlapping hits per pad

    init(engine: AVAudioEngine) { self.engine = engine }

    /// Call once, before starting the engine.
    func load(pads: [String]) throws {
        for pad in pads {
            guard let url = Bundle.main.url(forResource: pad, withExtension: &quot;caf&quot;) else {
                throw CocoaError(.fileNoSuchFile)
            }
            let file = try AVAudioFile(forReading: url)
            guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat,
                                                frameCapacity: AVAudioFrameCount(file.length)) else {
                throw CocoaError(.fileReadCorruptFile)
            }
            try file.read(into: buffer)
            buffers[pad] = buffer

            pools[pad] = (0..&amp;lt;voicesPerPad).map { _ in
                let node = AVAudioPlayerNode()
                engine.attach(node)
                engine.connect(node, to: engine.mainMixerNode, format: buffer.format)
                return node
            }
            nextIndex[pad] = 0
        }
    }

    /// Call after engine.start().
    func startNodes() {
        pools.values.flatMap { $0 }.forEach { $0.play() }
    }

    func hit(_ pad: String, velocity: Float = 1.0) {
        guard let buffer = buffers[pad], let pool = pools[pad], let i = nextIndex[pad] else { return }
        let node = pool[i]
        nextIndex[pad] = (i + 1) % pool.count
        node.volume = velocity
        // .interrupts: if this voice is still ringing from 4 hits ago, restart it cleanly.
        node.scheduleBuffer(buffer, at: nil, options: .interrupts, completionHandler: nil)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let engine = AVAudioEngine()
let piano = PitchedInstrument(engine: engine)
let drums = DrumKit(engine: engine)

try InstrumentSession.activate()
try piano.loadSoundBank(named: &quot;Piano&quot;)
try drums.load(pads: [&quot;kick&quot;, &quot;snare&quot;, &quot;hihat-closed&quot;, &quot;hihat-open&quot;, &quot;crash&quot;, &quot;tom-high&quot;, &quot;tom-low&quot;])
engine.prepare()
try engine.start()
drums.startNodes()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why a pool? A single player node plays one schedule queue. Scheduling a new hit with &lt;code&gt;.interrupts&lt;/code&gt; on a &lt;em&gt;single&lt;/em&gt; node cuts the previous hit off, and that's audible on cymbals. With four voices per pad, four overlapping hits all ring naturally, and only the fifth recycles the oldest voice. Ship samples as uncompressed &lt;code&gt;.caf&lt;/code&gt; or &lt;code&gt;.wav&lt;/code&gt; so decoding at launch is quick and exact. They're short, so the size cost is small.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-a-multi-touch-keyboard-that-feels-like-a-keyboard&quot;&gt;5. A multi-touch keyboard that feels like a keyboard&lt;/h3&gt;
&lt;p&gt;This is where many SwiftUI prototypes fall short. A &lt;code&gt;Button&lt;/code&gt; per key fires on touch-&lt;em&gt;up&lt;/em&gt;, which is far too late for an instrument. A &lt;code&gt;DragGesture&lt;/code&gt; per key doesn't hand off to the next key when the finger slides. And coordinating several simultaneous fingers through SwiftUI gestures is awkward.&lt;/p&gt;
&lt;p&gt;The direct approach is one UIKit view for the whole keyboard that tracks every &lt;code&gt;UITouch&lt;/code&gt; itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UIKit

final class KeyboardView: UIView {
    var firstMidi: UInt8 = 60           // C4
    var octaves = 2
    var onNoteOn: ((UInt8) -&amp;gt; Void)?
    var onNoteOff: ((UInt8) -&amp;gt; Void)?

    private var touchNotes: [ObjectIdentifier: UInt8] = [:]   // which finger holds which note

    override init(frame: CGRect) {
        super.init(frame: frame)
        isMultipleTouchEnabled = true      // essential: off by default
    }
    required init?(coder: NSCoder) { fatalError(&quot;init(coder:) is not supported&quot;) }

    // MARK: Touch tracking

    override func touchesBegan(_ touches: Set&amp;lt;UITouch&amp;gt;, with event: UIEvent?) {
        for t in touches { press(t) }
    }

    override func touchesMoved(_ touches: Set&amp;lt;UITouch&amp;gt;, with event: UIEvent?) {
        for t in touches {
            let id = ObjectIdentifier(t)
            let newNote = note(at: t.location(in: self))
            guard newNote != touchNotes[id] else { continue }    // still on the same key
            if let old = touchNotes[id] { onNoteOff?(old) }       // glissando: release old…
            touchNotes[id] = newNote
            if let n = newNote { onNoteOn?(n) }                   // …and press new
            setNeedsDisplay()
        }
    }

    override func touchesEnded(_ touches: Set&amp;lt;UITouch&amp;gt;, with event: UIEvent?) {
        for t in touches { release(t) }
    }

    override func touchesCancelled(_ touches: Set&amp;lt;UITouch&amp;gt;, with event: UIEvent?) {
        for t in touches { release(t) }     // never leave a note stuck on
    }

    private func press(_ t: UITouch) {
        guard let n = note(at: t.location(in: self)) else { return }
        touchNotes[ObjectIdentifier(t)] = n
        onNoteOn?(n)
        setNeedsDisplay()
    }

    private func release(_ t: UITouch) {
        if let n = touchNotes.removeValue(forKey: ObjectIdentifier(t)) { onNoteOff?(n) }
        setNeedsDisplay()
    }

    // MARK: Geometry

    private static let whiteOffsets: [UInt8] = [0, 2, 4, 5, 7, 9, 11]          // C D E F G A B
    private static let blackOffsets: [(after: Int, semitone: UInt8)] =
        [(0, 1), (1, 3), (3, 6), (4, 8), (5, 10)]                               // C# D# F# G# A#

    private var whiteKeyWidth: CGFloat { bounds.width / CGFloat(7 * octaves) }

    /// Black keys sit on top, so test them first.
    func note(at p: CGPoint) -&amp;gt; UInt8? {
        guard bounds.contains(p) else { return nil }
        let w = whiteKeyWidth
        if p.y &amp;lt; bounds.height * 0.6 {
            for octave in 0..&amp;lt;octaves {
                for (after, semitone) in Self.blackOffsets {
                    let x = CGFloat(octave * 7 + after + 1) * w - w * 0.3
                    if CGRect(x: x, y: 0, width: w * 0.6, height: bounds.height * 0.6).contains(p) {
                        return firstMidi + UInt8(octave * 12) + semitone
                    }
                }
            }
        }
        let index = Int(p.x / w)
        let octave = index / 7, step = index % 7
        return firstMidi + UInt8(octave * 12) + Self.whiteOffsets[step]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The essential details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;isMultipleTouchEnabled = true&lt;/code&gt;.&lt;/strong&gt; It's &lt;code&gt;false&lt;/code&gt; by default, and without it you'll only ever get one finger.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track notes per touch&lt;/strong&gt; with &lt;code&gt;ObjectIdentifier(touch)&lt;/code&gt;. Each finger owns its note, so chords and glissandos work independently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle &lt;code&gt;touchesCancelled&lt;/code&gt;.&lt;/strong&gt; A system gesture or an incoming call can cancel touches. If you don't release notes there, you get stuck notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black keys are hit-tested first&lt;/strong&gt;, because they're drawn on top.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On iOS 12 this is the only option, since SwiftUI needs iOS 13. Even on a SwiftUI app today, I'd wrap this view in a &lt;code&gt;UIViewRepresentable&lt;/code&gt;. Touch handling for an instrument is one of the places where UIKit is still simply the better tool.&lt;/p&gt;
&lt;p&gt;Drum pads follow the same idea in simpler form: a grid of rectangles, &lt;code&gt;touchesBegan&lt;/code&gt; triggers &lt;code&gt;hit(_:)&lt;/code&gt;, and there's no note-off.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-teaching-note-names-and-positions&quot;&gt;6. Teaching note names and positions&lt;/h3&gt;
&lt;p&gt;The listing describes the app as &quot;teaching beginners the names &amp;amp; positions of notes on the piano&quot;. On the keyboard view, that's two small things: draw a label on each key, and let learners switch between naming systems.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum NoteNaming: String, CaseIterable {
    case letters      // C D E F G A B
    case solfege      // Do Re Mi Fa Sol La Si

    static let letterNames = [&quot;C&quot;, &quot;C♯&quot;, &quot;D&quot;, &quot;D♯&quot;, &quot;E&quot;, &quot;F&quot;, &quot;F♯&quot;, &quot;G&quot;, &quot;G♯&quot;, &quot;A&quot;, &quot;A♯&quot;, &quot;B&quot;]
    static let solfegeNames = [&quot;Do&quot;, &quot;Do♯&quot;, &quot;Re&quot;, &quot;Re♯&quot;, &quot;Mi&quot;, &quot;Fa&quot;, &quot;Fa♯&quot;, &quot;Sol&quot;, &quot;Sol♯&quot;, &quot;La&quot;, &quot;La♯&quot;, &quot;Si&quot;]

    func name(for midi: UInt8, withOctave: Bool = true) -&amp;gt; String {
        let pitchClass = Int(midi) % 12
        let base = self == .letters ? Self.letterNames[pitchClass] : Self.solfegeNames[pitchClass]
        return withOctave ? &quot;\(base)\(Int(midi) / 12 - 1)&quot; : base      // MIDI 60 = C4
    }
}

// NoteNaming.letters.name(for: 60) == &quot;C4&quot;; NoteNaming.solfege.name(for: 64) == &quot;Mi4&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;code&gt;draw(_:)&lt;/code&gt;, draw white-key labels near the bottom of each key and black-key labels only when a &quot;show sharps&quot; toggle is on; for beginners, too many labels is noise. Highlighting the pressed key matters more than any label. The immediate visual &quot;this is the key you just played&quot; is how position is learned.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-real-world-example-drums-n-piano&quot;&gt;7. Real-world example: Drums n Piano&lt;/h3&gt;
&lt;p&gt;This is the approach I'd use, and recommend, for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/drums-n-piano/id1642061667&quot;&gt;Drums n Piano&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-1.jpg&quot; alt=&quot;Drums n Piano App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Music&lt;/strong&gt; and &lt;strong&gt;Entertainment&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;September 2022&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;9 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.4 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 12.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Simply play and learn the piano, drums or xylophone wherever you are&quot; &lt;/td&gt;&lt;td&gt;A sampler per pitched instrument; a player-node pool for drums (sections 3–4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;This virtual app sounds real, just like the original instrument!&quot; / &quot;Premium sound quality&quot; &lt;/td&gt;&lt;td&gt;Real samples loaded once, a low-latency session, natural note-off releases (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Teaching beginners the names &amp;amp; positions of notes on the piano&quot; &lt;/td&gt;&lt;td&gt;Note labels and pressed-key highlighting (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Play or learn your music chords&quot; &lt;/td&gt;&lt;td&gt;Per-touch tracking with multi-touch enabled (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-2.jpg&quot; alt=&quot;Drums n Piano App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing's audience is broad: &quot;a music teacher, singer, songwriter, or beginner or joyful kid&quot;. That's a good reminder that kids mash several keys with their whole hand. A multi-touch view that never leaves a stuck note is worth more than any feature.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-before-you-ship-a-virtual-instrument-a-checklist&quot;&gt;8. Before you ship a virtual instrument: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nothing loads on tap.&lt;/strong&gt; Samples and sound banks load at launch or on instrument switch; taps only trigger notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is &lt;code&gt;.playback&lt;/code&gt;&lt;/strong&gt; (or a deliberate &lt;code&gt;.ambient&lt;/code&gt;), with a small preferred buffer, and you've logged what you actually got.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polyphony is tested.&lt;/strong&gt; Ten-finger chords on the piano; rapid repeated hits on every drum pad.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There are no stuck notes.&lt;/strong&gt; Test cancelled touches, a phone call mid-chord, backgrounding mid-note and Control Center swipes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;isMultipleTouchEnabled&lt;/code&gt; is on&lt;/strong&gt;, and each touch owns its note.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glissando works&lt;/strong&gt;: sliding across keys releases and presses notes as you go.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sample licences are cleared&lt;/strong&gt; for everything you bundle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You've tested on real hardware with wired and Bluetooth audio&lt;/strong&gt;, and the app tells users that wired is tighter.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;A virtual instrument is mostly preparation: decode everything up front, pick the right player for the job (a sampler for pitched instruments, a node pool for one-shots), keep the audio buffer small, and track every finger yourself. Get those right, and the sound arrives with the touch, which is all a player really asks for.&lt;/p&gt;
&lt;p&gt;Play it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drums n Piano (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/drums-n-piano/id1642061667&quot;&gt;https://apps.apple.com/us/app/drums-n-piano/id1642061667&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you've built an instrument app and found a better way to handle touches or voices, tell me about it in the comments. I'm especially curious about anyone doing velocity from touch force or radius.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&quot;&gt;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32679/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</guid>
<pubDate>Tue, 27 Oct 2026 12:40:37 +0000</pubDate>
</item>
<item>
<title>Building a Real-Time Mic-to-Speaker App on iOS with AVAudioEngine</title>
<link>https://coderlegion.com/32678/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&quot;&gt;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Live audio passthrough in Swift: session setup, permissions, feedback, interruptions and route changes, from shipping an app that does exactly this&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-icon-512.jpg&quot; alt=&quot;Virtual Mic app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Most audio tutorials on iOS cover one of two things: playing a file, or recording one. Very few cover the strange in-between case where the microphone and the speaker are both live at the same time, and whatever goes in comes straight back out.&lt;/p&gt;
&lt;p&gt;That in-between case is exactly what my app &lt;strong&gt;Mic to Speaker – Virtual Mic&lt;/strong&gt; does. You plug your iPhone into a speaker, or connect over Bluetooth or AirPlay, tap the screen, and your voice comes out of the speaker. I first shipped it in May 2020. Since then it has collected more than 3,400 ratings on the US App Store, and it is still the most popular of the 24 free apps I have published under my developer name.&lt;/p&gt;
&lt;p&gt;It looks like a weekend project, and the first working version really is only a few dozen lines of Swift. But the gap between &quot;it works on my desk&quot; and &quot;it works for thousands of people on random speakers, in cars, during phone calls&quot; is where the real work lives.&lt;/p&gt;
&lt;p&gt;This is the guide I wish I'd had: permissions, session setup, the engine graph, feedback, interruptions, route changes, background audio and App Store review. I've checked API names and availability against Apple's developer documentation and flag what's new or recently changed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-problem-what-mic-to-speaker-really-needs&quot;&gt;1. The problem: what &quot;mic to speaker&quot; really needs&lt;/h3&gt;
&lt;p&gt;On paper the requirement is one sentence: &lt;em&gt;take audio from the microphone and play it through the speaker, right now.&lt;/em&gt; In practice that sentence hides three hard requirements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Low latency.&lt;/strong&gt; When you hear your own voice back, your brain notices delay quickly. A little sounds &quot;roomy&quot;; more sounds like an echo and makes it hard to keep talking. Every buffer between mic and speaker adds delay, so you want the smallest buffers the device can reliably handle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The right output route.&lt;/strong&gt; By default, when an iPhone records and plays at the same time, output goes to the &lt;em&gt;receiver&lt;/em&gt;, the small earpiece you hold to your ear for calls. Users expect the loud bottom speaker, their Bluetooth speaker, the car stereo or an AirPlay device. Getting audio to the right place, and keeping it there, is half the job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No howling feedback.&lt;/strong&gt; If the mic can hear the speaker, the sound loops and grows into that painful squeal everyone knows from school assemblies. On a phone, with mic and speaker centimetres apart, that's the default outcome unless you plan for it.&lt;/p&gt;
&lt;p&gt;I underestimated all three. My first build played through the earpiece. The second howled as soon as I turned the volume up. The third worked beautifully until a phone call came in, then stayed silent until I killed the app. Each section below is, roughly, one of those lessons.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-microphone-permission-the-modern-way&quot;&gt;2. Microphone permission, the modern way&lt;/h3&gt;
&lt;p&gt;Before anything else, you need the user's permission to use the microphone. Two things are required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First, a purpose string.&lt;/strong&gt; Add &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt; to your &lt;code&gt;Info.plist&lt;/code&gt;. Apple's documentation is blunt about this: if your app tries to access the microphone without this key, the app exits. Make it specific, for example: &lt;em&gt;&quot;Virtual Mic uses the microphone to play your voice live through your speaker or headphones.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, the request itself.&lt;/strong&gt; In iOS 17, Apple moved record permission from &lt;code&gt;AVAudioSession&lt;/code&gt; to a new class, &lt;code&gt;AVAudioApplication&lt;/code&gt;. The old &lt;code&gt;AVAudioSession.requestRecordPermission(_:)&lt;/code&gt; is deprecated as of iOS 17. The new API has both a completion-handler version and an &lt;code&gt;async&lt;/code&gt; version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class func requestRecordPermission() async -&amp;gt; Bool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your deployment target is below iOS 17, you need both paths. Mic to Speaker currently supports iOS 16.6 and later, so this is not theoretical for me. Here's a small helper that handles both, plus the &quot;denied&quot; case:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation
import UIKit

@MainActor
enum MicPermissionManager {

    /// Prompts if needed. Returns true if we may record.
    static func request() async -&amp;gt; Bool {
        if #available(iOS 17.0, *) {
            return await AVAudioApplication.requestRecordPermission()
        } else {
            return await withCheckedContinuation { continuation in
                AVAudioSession.sharedInstance().requestRecordPermission { granted in
                    continuation.resume(returning: granted)
                }
            }
        }
    }

    /// The system will not show the prompt again after a denial,
    /// so the only way forward is the Settings app.
    static func openSettings() {
        guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
        UIApplication.shared.open(url)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three practical notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ask at the moment of need&lt;/strong&gt;, when the user first taps &quot;start&quot;, not at launch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Denied is permanent from the app's side.&lt;/strong&gt; The system won't prompt again, so show a short explanation and a button that calls &lt;code&gt;openSettings()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silence is not an error.&lt;/strong&gt; Apple's docs note that without permission the app captures only silence. The engine starts &quot;fine&quot; and plays nothing, so always check permission first or you'll get confusing &quot;no sound&quot; bug reports.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-configuring-avaudiosession&quot;&gt;3. Configuring AVAudioSession&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AVAudioSession&lt;/code&gt; is how your app tells iOS what kind of audio it does. Get this wrong and nothing else matters.&lt;/p&gt;
&lt;h4 id=&quot;category-and-options&quot;&gt;Category and options&lt;/h4&gt;
&lt;p&gt;For simultaneous input and output you need the &lt;code&gt;.playAndRecord&lt;/code&gt; category. On top of that, the options do most of the heavy lifting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.defaultToSpeaker&lt;/code&gt;&lt;/strong&gt;: uses the built-in loudspeaker instead of the earpiece when nothing else is connected. This one option fixes &quot;why is it so quiet?&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowBluetoothA2DP&lt;/code&gt;&lt;/strong&gt;: lets output go to high-quality Bluetooth speakers and headphones. A2DP is output-only, so the iPhone's mic stays the input, which is usually what a mic-to-speaker app wants.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowBluetoothHFP&lt;/code&gt;&lt;/strong&gt; (formerly &lt;code&gt;.allowBluetooth&lt;/code&gt;): lets Bluetooth &lt;em&gt;hands-free&lt;/em&gt; devices act as input and output. Apple's current docs mark the old &lt;code&gt;.allowBluetooth&lt;/code&gt; name deprecated in favour of &lt;code&gt;.allowBluetoothHFP&lt;/code&gt;; on an older SDK, use &lt;code&gt;.allowBluetooth&lt;/code&gt;. HFP is the &quot;phone call&quot; profile with noticeably lower quality than A2DP, so I enable it only when the user wants a headset's own microphone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowAirPlay&lt;/code&gt;&lt;/strong&gt;: lets output go to AirPlay devices; for &lt;code&gt;.playAndRecord&lt;/code&gt; you must set it explicitly. AirPlay buffers over the network, so expect extra latency you can't control.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;mode&quot;&gt;Mode&lt;/h4&gt;
&lt;p&gt;The mode tunes the session for a use case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.default&lt;/code&gt;&lt;/strong&gt;: no special processing; best for music and singing, and required for echo-cancelled input (below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.voiceChat&lt;/code&gt;&lt;/strong&gt;: tuned for two-way voice. Apple's docs say it optimises EQ for voice, limits routes to voice-suitable ones and automatically applies the Bluetooth HFP option. Great for speech, not for music.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.measurement&lt;/code&gt;&lt;/strong&gt;: minimises system signal processing for the rawest input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I default to &lt;code&gt;.default&lt;/code&gt; and offer voice processing (section 5) as a user choice.&lt;/p&gt;
&lt;h4 id=&quot;buffer-duration-and-sample-rate&quot;&gt;Buffer duration and sample rate&lt;/h4&gt;
&lt;p&gt;You can &lt;em&gt;ask&lt;/em&gt; for a smaller I/O buffer and a particular sample rate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;try session.setPreferredIOBufferDuration(0.005)  // a request, not a guarantee
try session.setPreferredSampleRate(48_000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are &lt;em&gt;preferences&lt;/em&gt;; the system decides based on hardware and route. After activating, read &lt;code&gt;ioBufferDuration&lt;/code&gt; and &lt;code&gt;sampleRate&lt;/code&gt; to see what you actually got, and log them. Bluetooth routes often differ from what you asked for.&lt;/p&gt;
&lt;h4 id=&quot;echo-cancelled-input-ios-18-2-&quot;&gt;Echo-cancelled input (iOS 18.2+)&lt;/h4&gt;
&lt;p&gt;Starting with &lt;strong&gt;iOS 18.2&lt;/strong&gt;, &lt;code&gt;AVAudioSession&lt;/code&gt; has three new members:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;isEchoCancelledInputAvailable&lt;/code&gt; — whether the device and current setup support it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setPrefersEchoCancelledInput(_:)&lt;/code&gt; — ask for it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isEchoCancelledInputEnabled&lt;/code&gt; — whether the system actually turned it on&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Per Apple's docs, it works only with &lt;code&gt;.playAndRecord&lt;/code&gt; and &lt;code&gt;.default&lt;/code&gt; mode, on certain 2024-or-later iPhones, on the built-in mic and speaker route. It's tuned for a wider range of sounds than voice processing, and can switch off when the route changes (say, a headset is plugged in). Treat it as a bonus on supported hardware, not your feedback strategy.&lt;/p&gt;
&lt;h4 id=&quot;temporary-speaker-override&quot;&gt;Temporary speaker override&lt;/h4&gt;
&lt;p&gt;For a &quot;force loudspeaker&quot; toggle, use &lt;code&gt;overrideOutputAudioPort(_:)&lt;/code&gt;. It temporarily changes the current route, and the override clears when the route changes again.&lt;/p&gt;
&lt;h4 id=&quot;putting-it-together&quot;&gt;Putting it together&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

enum AudioSessionConfigurator {

    static func configure(allowBluetoothMic: Bool = false,
                          preferEchoCancellation: Bool = true) throws {
        let session = AVAudioSession.sharedInstance()

        var options: AVAudioSession.CategoryOptions = [
            .defaultToSpeaker,
            .allowBluetoothA2DP,
            .allowAirPlay
        ]
        if allowBluetoothMic {
            // Use .allowBluetooth if your SDK predates .allowBluetoothHFP.
            options.insert(.allowBluetoothHFP)
        }

        try session.setCategory(.playAndRecord, mode: .default, options: options)
        try session.setPreferredSampleRate(48_000)
        try session.setPreferredIOBufferDuration(0.005)

        if #available(iOS 18.2, *),
           preferEchoCancellation,
           session.isEchoCancelledInputAvailable {
            try session.setPrefersEchoCancelledInput(true)
        }

        try session.setActive(true)

        print(&quot;Sample rate: \(session.sampleRate) Hz, &quot;
            + &quot;IO buffer: \(session.ioBufferDuration * 1000) ms, &quot;
            + &quot;route: \(session.currentRoute.outputs.map(\.portType.rawValue))&quot;)
    }

    /// Force the built-in speaker (true) or go back to the normal route (false).
    static func setSpeakerOverride(_ on: Bool) throws {
        try AVAudioSession.sharedInstance()
            .overrideOutputAudioPort(on ? .speaker : .none)
    }

    static func deactivate() {
        try? AVAudioSession.sharedInstance()
            .setActive(false, options: .notifyOthersOnDeactivation)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-avaudioengine-graph&quot;&gt;4. The AVAudioEngine graph&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AVAudioEngine&lt;/code&gt; lets you connect audio nodes like guitar pedals. For mic-to-speaker the graph is short:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;inputNode → [EQ] → [Reverb] → mainMixerNode → outputNode
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;inputNode&lt;/code&gt;&lt;/strong&gt; is the current microphone input.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AVAudioUnitEQ&lt;/code&gt;&lt;/strong&gt; gives bass/treble bands and a &lt;code&gt;globalGain&lt;/code&gt; boost (documented range −96 to +24 dB).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AVAudioUnitReverb&lt;/code&gt;&lt;/strong&gt; adds room character; &lt;code&gt;wetDryMix&lt;/code&gt; runs 0–100%, where 0 is fully dry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mainMixerNode&lt;/code&gt;&lt;/strong&gt; is created and wired to &lt;code&gt;outputNode&lt;/code&gt; the first time you access it. Its &lt;code&gt;outputVolume&lt;/code&gt; (0.0–1.0) is a natural master volume.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;format-matching&quot;&gt;Format matching&lt;/h4&gt;
&lt;p&gt;The most common live-audio crash is a format mismatch. My rule: &lt;strong&gt;use the input node's own format for every connection carrying the mic signal&lt;/strong&gt;, and let the mixer convert for the output hardware. Also validate it first: with no input available, the format can come back with a 0 Hz sample rate or zero channels, and connecting with that raises an exception Swift can't catch.&lt;/p&gt;
&lt;h4 id=&quot;the-complete-class&quot;&gt;The complete class&lt;/h4&gt;
&lt;p&gt;Here's a minimal, complete engine class: one object that owns the engine, exposes a few controls and starts and stops cleanly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

enum LiveAudioError: Error {
    case noInputAvailable
}

final class LiveAudioEngine {

    private let engine = AVAudioEngine()
    private let eq = AVAudioUnitEQ(numberOfBands: 2)
    private let reverb = AVAudioUnitReverb()

    private(set) var isRunning = false

    // MARK: Controls

    /// Master volume, 0.0 ... 1.0
    var volume: Float {
        get { engine.mainMixerNode.outputVolume }
        set { engine.mainMixerNode.outputVolume = min(max(newValue, 0), 1) }
    }

    /// Extra input gain in dB, clamped to a sane range.
    var boostDB: Float {
        get { eq.globalGain }
        set { eq.globalGain = min(max(newValue, -24), 24) }
    }

    /// Bass and treble in dB.
    var bassDB: Float {
        get { eq.bands[0].gain }
        set { eq.bands[0].gain = min(max(newValue, -12), 12) }
    }
    var trebleDB: Float {
        get { eq.bands[1].gain }
        set { eq.bands[1].gain = min(max(newValue, -12), 12) }
    }

    /// Reverb amount, 0 (off) ... 100
    var reverbMix: Float {
        get { reverb.wetDryMix }
        set { reverb.wetDryMix = min(max(newValue, 0), 100) }
    }

    // MARK: Setup

    init() {
        let bass = eq.bands[0]
        bass.filterType = .lowShelf
        bass.frequency = 150
        bass.gain = 0
        bass.bypass = false

        let treble = eq.bands[1]
        treble.filterType = .highShelf
        treble.frequency = 6_000
        treble.gain = 0
        treble.bypass = false

        reverb.loadFactoryPreset(.mediumRoom)
        reverb.wetDryMix = 0

        engine.attach(eq)
        engine.attach(reverb)
    }

    // MARK: Start / stop

    func start() throws {
        guard !isRunning else { return }

        let input = engine.inputNode
        let format = input.outputFormat(forBus: 0)
        guard format.sampleRate &amp;gt; 0, format.channelCount &amp;gt; 0 else {
            throw LiveAudioError.noInputAvailable
        }

        // Rebuild connections every time: the input format can change
        // between runs (new route, new sample rate).
        engine.disconnectNodeOutput(input)
        engine.disconnectNodeOutput(eq)
        engine.disconnectNodeOutput(reverb)

        engine.connect(input, to: eq, format: format)
        engine.connect(eq, to: reverb, format: format)
        engine.connect(reverb, to: engine.mainMixerNode, format: format)

        engine.prepare()
        try engine.start()
        isRunning = true
    }

    func stop() {
        guard isRunning else { return }
        engine.stop()
        isRunning = false
    }

    /// Voice processing (echo cancellation, AGC, noise suppression).
    /// Must be changed while the engine is stopped.
    func setVoiceProcessing(_ enabled: Bool) throws {
        let wasRunning = isRunning
        stop()
        try engine.inputNode.setVoiceProcessingEnabled(enabled)
        if wasRunning { try start() }
    }

    /// Exposed so the owner can observe engine configuration changes.
    var underlyingEngine: AVAudioEngine { engine }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And using it from a view model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@MainActor
final class MicViewModel: ObservableObject {
    @Published var isLive = false
    @Published var errorMessage: String?

    let audio = LiveAudioEngine()

    func toggle() async {
        if audio.isRunning {
            audio.stop()
            AudioSessionConfigurator.deactivate()
            isLive = false
            return
        }

        guard await MicPermissionManager.request() else {
            errorMessage = &quot;Microphone access is off. Turn it on in Settings.&quot;
            return
        }

        do {
            try AudioSessionConfigurator.configure()
            audio.volume = 0.3          // start low, always
            try audio.start()
            isLive = true
        } catch {
            errorMessage = &quot;Couldn't start audio: \(error.localizedDescription)&quot;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note &lt;code&gt;audio.volume = 0.3&lt;/code&gt;. Starting quiet is a safety feature, not a nicety.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-taming-feedback-and-latency&quot;&gt;5. Taming feedback and latency&lt;/h3&gt;
&lt;p&gt;No single switch removes feedback. There are trade-offs; pick sensible defaults and let the user adjust.&lt;/p&gt;
&lt;h4 id=&quot;headphones-vs-speaker&quot;&gt;Headphones vs. speaker&lt;/h4&gt;
&lt;p&gt;The most reliable fix is physics: if the mic can't hear the speaker, there's no loop. With headphones, feedback basically disappears. With a Bluetooth speaker across the room, it's manageable. With the phone's own loudspeaker centimetres from the mic, you're fighting acoustics at any useful volume.&lt;/p&gt;
&lt;p&gt;That's why my App Store listing has plain &quot;Safety tips&quot;: start with low volume, connect speakers before turning on, increase volume gradually, keep the device away from speakers. They came straight from user reviews and support emails.&lt;/p&gt;
&lt;h4 id=&quot;voice-processing&quot;&gt;Voice processing&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;setVoiceProcessingEnabled(_:)&lt;/code&gt; (on &lt;code&gt;AVAudioIONode&lt;/code&gt;, iOS 13+) turns on Apple's voice processing I/O: echo cancellation, automatic gain control and noise suppression, the same family used for calls. &lt;code&gt;isVoiceProcessingAGCEnabled&lt;/code&gt; on the input node lets you keep echo cancellation but drop automatic gain.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Change it while the engine is stopped.&lt;/strong&gt; It reconfigures the I/O unit and the input format can change, which is why &lt;code&gt;LiveAudioEngine&lt;/code&gt; rebuilds connections on every &lt;code&gt;start()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It changes the sound.&lt;/strong&gt; Tuned for speech, it can make singing and instruments sound processed, so I make it a user choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's not magic.&lt;/strong&gt; It reduces the loop; at high enough volume on the built-in speaker you can still get feedback.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;buffer-size-trade-offs&quot;&gt;Buffer size trade-offs&lt;/h4&gt;
&lt;p&gt;Smaller buffers mean lower latency but less time for the audio thread before the next deadline; miss it and you get clicks and dropouts. Larger buffers are smoother, but you hear yourself later. My approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Request a small buffer, then read back what you got.&lt;/li&gt;
&lt;li&gt;Keep the chain light. Apple's built-in EQ and reverb are fine; heavy custom DSP is where trouble starts.&lt;/li&gt;
&lt;li&gt;Never allocate, lock or do file I/O on the real-time thread. In a tap (level meter, recording), copy what you need and hand it off.&lt;/li&gt;
&lt;li&gt;Accept that Bluetooth and AirPlay add buffering you can't remove. I tell users plainly that wired gives the lowest delay.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm deliberately not quoting millisecond numbers. Latency depends on device, iOS version, route and granted buffer size; the only honest number is the one you measure on your own devices.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-interruptions-and-route-changes&quot;&gt;6. Interruptions and route changes&lt;/h3&gt;
&lt;p&gt;This separates a demo from a shipped app. Real phones get calls, alarms, Siri, other apps taking over audio, and headphones unplugged mid-sentence.&lt;/p&gt;
&lt;h4 id=&quot;interruptions&quot;&gt;Interruptions&lt;/h4&gt;
&lt;p&gt;When another session (say, a phone call) takes over, the system posts &lt;code&gt;AVAudioSession.interruptionNotification&lt;/code&gt;. On &lt;code&gt;.began&lt;/code&gt;, the system has already stopped your audio; update state and UI. On &lt;code&gt;.ended&lt;/code&gt;, the options may include &lt;code&gt;.shouldResume&lt;/code&gt;, a hint that resuming without user input is appropriate.&lt;/p&gt;
&lt;p&gt;What's new: Apple's latest docs mark &lt;code&gt;.shouldResume&lt;/code&gt; deprecated as of iOS 27 in favour of a newer &lt;code&gt;resumptionRecommendationNotification&lt;/code&gt;. It still works on the versions most apps support, so the sample uses it; on the newest SDK, see Apple's &quot;Handling audio interruptions&quot; article.&lt;/p&gt;
&lt;p&gt;For a live mic I'm conservative: auto-resume only if the user was live before &lt;em&gt;and&lt;/em&gt; the system says resuming is fine. A mic that silently turns itself back on after a call is a privacy surprise.&lt;/p&gt;
&lt;h4 id=&quot;route-changes&quot;&gt;Route changes&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;AVAudioSession.routeChangeNotification&lt;/code&gt; carries a reason. The critical one is &lt;strong&gt;&lt;code&gt;.oldDeviceUnavailable&lt;/code&gt;&lt;/strong&gt;: headphones unplugged or Bluetooth disconnected. Apple's guidance for media apps is to pause; for a live mic it matters even more, because audio jumps to the loudspeaker and can instantly howl. Stop first, let the user decide. For &lt;code&gt;.newDeviceAvailable&lt;/code&gt;, keep going but rebuild the graph, since the input format may have changed.&lt;/p&gt;
&lt;h4 id=&quot;media-services-reset&quot;&gt;Media services reset&lt;/h4&gt;
&lt;p&gt;Rarely, the media server restarts and the system posts &lt;code&gt;AVAudioSession.mediaServicesWereResetNotification&lt;/code&gt;. Every audio object is now invalid: recreate the engine and reconfigure the session. Separately, &lt;code&gt;AVAudioEngine&lt;/code&gt; posts &lt;code&gt;.AVAudioEngineConfigurationChange&lt;/code&gt; when the hardware I/O format changes; the engine stops, and you reconnect and restart.&lt;/p&gt;
&lt;h4 id=&quot;putting-it-together-1&quot;&gt;Putting it together&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

@MainActor
final class AudioEventsHandler {

    private var engine: LiveAudioEngine
    private var wasLiveBeforeInterruption = false
    private var observers: [NSObjectProtocol] = []
    var onStateChange: ((Bool) -&amp;gt; Void)?

    init(engine: LiveAudioEngine) {
        self.engine = engine
        observe()
    }

    deinit {
        observers.forEach { NotificationCenter.default.removeObserver($0) }
    }

    private func observe() {
        let center = NotificationCenter.default
        let session = AVAudioSession.sharedInstance()

        observers.append(center.addObserver(
            forName: AVAudioSession.interruptionNotification,
            object: session, queue: .main) { [weak self] note in
                MainActor.assumeIsolated { self?.handleInterruption(note) }
            })

        observers.append(center.addObserver(
            forName: AVAudioSession.routeChangeNotification,
            object: session, queue: .main) { [weak self] note in
                MainActor.assumeIsolated { self?.handleRouteChange(note) }
            })

        observers.append(center.addObserver(
            forName: AVAudioSession.mediaServicesWereResetNotification,
            object: session, queue: .main) { [weak self] _ in
                MainActor.assumeIsolated { self?.handleMediaServicesReset() }
            })

        observers.append(center.addObserver(
            forName: .AVAudioEngineConfigurationChange,
            object: engine.underlyingEngine, queue: .main) { [weak self] _ in
                MainActor.assumeIsolated { self?.restartIfNeeded() }
            })
    }

    private func handleInterruption(_ note: Notification) {
        guard let info = note.userInfo,
              let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }

        switch type {
        case .began:
            wasLiveBeforeInterruption = engine.isRunning
            engine.stop()
            onStateChange?(false)

        case .ended:
            let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw)
            if wasLiveBeforeInterruption &amp;amp;&amp;amp; options.contains(.shouldResume) {
                do {
                    try AVAudioSession.sharedInstance().setActive(true)
                    try engine.start()
                    onStateChange?(true)
                } catch {
                    onStateChange?(false)
                }
            }
            wasLiveBeforeInterruption = false

        @unknown default:
            break
        }
    }

    private func handleRouteChange(_ note: Notification) {
        guard let info = note.userInfo,
              let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }

        switch reason {
        case .oldDeviceUnavailable:
            // Headphones unplugged / Bluetooth gone: stop before we howl.
            engine.stop()
            onStateChange?(false)
        case .newDeviceAvailable, .categoryChange, .override:
            restartIfNeeded()
        default:
            break
        }
    }

    private func handleMediaServicesReset() {
        // Every audio object is invalid now. Rebuild from scratch.
        engine.stop()
        engine = LiveAudioEngine()
        try? AudioSessionConfigurator.configure()
        onStateChange?(false) // let the user start again
    }

    private func restartIfNeeded() {
        guard engine.isRunning else { return }
        engine.stop()
        do {
            try engine.start()   // reconnects with the new input format
        } catch {
            onStateChange?(false)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In production you'd also re-register the configuration observer for the new engine after a reset and hand it back to its owner; I've kept the shape minimal.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-background-audio-and-app-store-notes&quot;&gt;7. Background audio and App Store notes&lt;/h3&gt;
&lt;h4 id=&quot;background-audio&quot;&gt;Background audio&lt;/h4&gt;
&lt;p&gt;To keep the mic live with the screen locked, add &lt;code&gt;audio&lt;/code&gt; to &lt;code&gt;UIBackgroundModes&lt;/code&gt; (Xcode: Background Modes → &quot;Audio, AirPlay, and Picture in Picture&quot;). With an active &lt;code&gt;.playAndRecord&lt;/code&gt; session and a running engine, iOS keeps the app alive and shows its microphone-in-use indicator, so users always know.&lt;/p&gt;
&lt;p&gt;The flip side: &lt;strong&gt;when the user stops, really stop.&lt;/strong&gt; Stop the engine and deactivate with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt; so paused music and podcasts can resume. Holding the session forever drains battery and blocks other audio.&lt;/p&gt;
&lt;h4 id=&quot;app-store-review-considerations&quot;&gt;App Store review considerations&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A specific purpose string&lt;/strong&gt;, in the user's words.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background audio must be real.&lt;/strong&gt; Declaring the &lt;code&gt;audio&lt;/code&gt; mode means actually producing audible content in the background; an idle app holding the session doesn't qualify.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An obvious &quot;live&quot; state&lt;/strong&gt;: a big on/off control and a clear indicator.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Honest privacy.&lt;/strong&gt; If you record sessions, say so and say where they're stored, and keep your App Privacy details consistent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't over-promise.&lt;/strong&gt; If the app can't act as a system-wide mic for a computer or other apps, don't imply it can.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-virtual-mic-a-k-a-mic-to-speaker&quot;&gt;8. Real-world example: Virtual Mic, a.k.a. Mic to Speaker&lt;/h3&gt;
&lt;p&gt;Everything above comes from building and maintaining my own apps. The main one is on the App Store as &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&quot;&gt;Mic to Speaker – Virtual Mic&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-1.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its public App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in the &lt;strong&gt;Music&lt;/strong&gt; and &lt;strong&gt;Utilities&lt;/strong&gt; categories&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;May 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;About &lt;strong&gt;3,400 ratings&lt;/strong&gt; in the US store, averaging around &lt;strong&gt;4.5 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.6 and later&lt;/strong&gt;, on iPhone and iPad&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing describes the core idea simply: &quot;Use your device as a live microphone! Connect to any speaker and your voice comes out loud and clear.&quot; Here's how the listing's features map to the techniques in this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;What makes it work&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Open the app and tap the screen. That's it!&quot; / &quot;One tap to turn on or off&quot; &lt;/td&gt;&lt;td&gt;Permission request on first tap (section 2), then session + engine start (sections 3–4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Connect via Bluetooth, AirPlay, or cable&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;.playAndRecord&lt;/code&gt; with &lt;code&gt;.defaultToSpeaker&lt;/code&gt;, &lt;code&gt;.allowBluetoothA2DP&lt;/code&gt;, &lt;code&gt;.allowAirPlay&lt;/code&gt; (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Works in the background&quot; / &quot;Runs even when screen is locked&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;UIBackgroundModes&lt;/code&gt; → &lt;code&gt;audio&lt;/code&gt;, active session, running engine (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Add echo and reverb… Adjust bass and treble. Make your voice louder or softer. Everything updates in real-time&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AVAudioUnitReverb&lt;/code&gt;, &lt;code&gt;AVAudioUnitEQ&lt;/code&gt; bands and gain, mixer volume, all live in the graph (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Every session is saved automatically… MP3, WAV, M4A&quot; &lt;/td&gt;&lt;td&gt;A tap on the graph feeding a file writer, off the real-time thread (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Safety tips: Start with low volume… Avoid feedback by keeping device away from speakers&quot; &lt;/td&gt;&lt;td&gt;The feedback realities in section 5, and starting at a low volume by default&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-2.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing's use cases range from live streaming, karaoke and podcasts to announcements and hearing assistance: amplifying voices in meetings or classes. That last one shapes my defaults most. For someone using your app to hear better, a feedback squeal or surprise route change isn't a glitch; it hurts. Hence &quot;start quiet&quot; and &quot;stop on &lt;code&gt;.oldDeviceUnavailable&lt;/code&gt;&quot;. It's also why the app still supports iOS 16.6 and carries the pre-iOS 17 permission path from section 2.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-3.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 3&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;android-sidebar-virtual-mic-on-google-play&quot;&gt;Android sidebar: Virtual Mic on Google Play&lt;/h4&gt;
&lt;p&gt;The same idea exists on Android as &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&quot;&gt;Virtual Mic on Google Play&lt;/a&gt;&lt;/strong&gt; (10K+ downloads at the time of writing). Its listing highlights real-time noise reduction, echo cancellation, reverb and echo effects, an equaliser, input gain and output volume controls, and support for Bluetooth speakers and wired headphones.&lt;/p&gt;
&lt;p&gt;On Android the building blocks are &lt;code&gt;AudioRecord&lt;/code&gt; (capture) and &lt;code&gt;AudioTrack&lt;/code&gt; (playback), or Google's &lt;strong&gt;Oboe&lt;/strong&gt; C++ library over AAudio/OpenSL ES for low-latency work. Same problems (latency, routing, feedback, audio focus instead of interruptions), very different APIs. That's my next article.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-live-audio-a-7-point-checklist&quot;&gt;9. Before you ship live audio: a 7-point checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Permission flow is complete.&lt;/strong&gt; &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt; is specific; you ask at the moment of need; iOS 17+ uses &lt;code&gt;AVAudioApplication&lt;/code&gt;, older versions fall back to &lt;code&gt;AVAudioSession&lt;/code&gt;; denied users get an &quot;Open Settings&quot; path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is configured on purpose.&lt;/strong&gt; &lt;code&gt;.playAndRecord&lt;/code&gt;, &lt;code&gt;.defaultToSpeaker&lt;/code&gt;, the Bluetooth options you actually want (A2DP for quality, HFP only when needed), and you log the real sample rate and buffer duration you got.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formats come from the hardware.&lt;/strong&gt; You connect using the input node's format, check it's valid, and rebuild connections whenever the route or configuration changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feedback has defaults.&lt;/strong&gt; Start at low volume, recommend headphones or distance, offer voice processing as a user choice, and use echo-cancelled input only where &lt;code&gt;isEchoCancelledInputAvailable&lt;/code&gt; says you can.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interruptions and route changes are tested on a real device.&lt;/strong&gt; Phone call, alarm, Siri, unplugging headphones, Bluetooth disconnecting, AirPlay switching: the app ends up in a sensible, visible state every time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background behaviour is honest.&lt;/strong&gt; The &lt;code&gt;audio&lt;/code&gt; background mode is declared only because you really play audio in the background, and you deactivate the session with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt; when the user stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The real-time thread stays clean.&lt;/strong&gt; No allocations, locks, logging or file I/O inside render callbacks or taps; heavy work is handed off.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;A mic-to-speaker app is a great way to learn iOS audio because there's nowhere to hide: latency is audible, routing mistakes are obvious, and a missed interruption leaves a silent app. &lt;code&gt;AVAudioSession&lt;/code&gt; plus &lt;code&gt;AVAudioEngine&lt;/code&gt; give you everything you need in a few hundred lines of Swift, as long as you respect the session, follow the hardware's formats and handle real-world events.&lt;/p&gt;
&lt;p&gt;If you want to see the result in action, try my apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mic to Speaker – Virtual Mic (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&quot;&gt;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Virtual Mic (Android):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&quot;&gt;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All 24 of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you've shipped live audio on iOS and hit a problem I didn't cover, I'd love to hear about it in the comments. Follow me here on Medium for the Android version of this guide.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&quot;&gt;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32678/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</guid>
<pubDate>Tue, 27 Oct 2026 12:40:22 +0000</pubDate>
</item>
<item>
<title>Text Plus Audio, Fully Offline: Building a Prayer App with AVAudioPlayer and Lock-Screen Controls</title>
<link>https://coderlegion.com/32186/text-plus-audio-fully-offline-building-prayer-app-with-avaudioplayer-lock-screen-controls</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&quot;&gt;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Bundled recitation audio, verse-by-verse highlighting from a cue sheet, background playback, Now Playing controls and the interruptions you must handle, on an app that still supports iOS 11&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-icon-512.jpg&quot; alt=&quot;Hanuman Chalisa app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Some apps are used in a very particular moment. A prayer app is opened in the morning, often before anything else, frequently by someone older who isn't interested in settings screens, and sometimes somewhere with no signal at all. It has to open instantly, play immediately and never ask for an account.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hanuman Chalisa : Offline&lt;/strong&gt; was the second app I published, in April 2020, one day after my Bible app. The Hanuman Chalisa is a devotional hymn to Lord Hanuman, attributed to the poet-saint Tulsidas. The app does two things: it shows the Chalisa in English-language script for reading, and it plays an audio recitation, all without an internet connection.&lt;/p&gt;
&lt;p&gt;That combination, text plus audio that stay in step, is a pattern you'll find in language-learning apps, audiobooks with text, karaoke lyrics and guided meditations. This article walks through how I'd build it on iOS: bundling text and audio, a cue sheet for verse timing, &lt;code&gt;AVAudioPlayer&lt;/code&gt; with a proper &lt;code&gt;.playback&lt;/code&gt; session, highlighting the current verse, lock-screen controls through &lt;code&gt;MPNowPlayingInfoCenter&lt;/code&gt; and &lt;code&gt;MPRemoteCommandCenter&lt;/code&gt;, and the interruptions and route changes a real device throws at you. The app supports iOS 11 and later, so the code is UIKit-first and stays on long-established APIs. I'm describing the approach I'd use and recommend, not the app's exact source.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-what-offline-text-plus-audio-requires&quot;&gt;1. What &quot;offline text plus audio&quot; requires&lt;/h3&gt;
&lt;p&gt;Three requirements shape the design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero network, zero setup.&lt;/strong&gt; Everything ships in the app bundle. First launch is the same as the hundredth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio and text are one experience.&lt;/strong&gt; When the recitation reaches a verse, that verse is highlighted and scrolled into view. When the user taps a verse, the audio jumps there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It behaves like a real audio app.&lt;/strong&gt; It keeps playing with the screen locked, shows controls on the Lock Screen and in Control Center, pauses for a phone call, and stops when headphones are pulled out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first two are about your data model. The third is about respecting the system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-bundle-the-content-with-a-cue-sheet&quot;&gt;2. Bundle the content with a cue sheet&lt;/h3&gt;
&lt;p&gt;I keep the text and the timing together in one small JSON file in the bundle, next to the audio file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;{
  &quot;title&quot;: &quot;Hanuman Chalisa&quot;,
  &quot;audio&quot;: &quot;chalisa.m4a&quot;,
  &quot;verses&quot;: [
    { &quot;id&quot;: 1, &quot;kind&quot;: &quot;doha&quot;,  &quot;start&quot;: 0.0,  &quot;text&quot;: &quot;…&quot; },
    { &quot;id&quot;: 2, &quot;kind&quot;: &quot;doha&quot;,  &quot;start&quot;: 14.2, &quot;text&quot;: &quot;…&quot; },
    { &quot;id&quot;: 3, &quot;kind&quot;: &quot;chaupai&quot;, &quot;start&quot;: 27.8, &quot;text&quot;: &quot;…&quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each verse carries the time, in seconds, at which its recitation &lt;strong&gt;starts&lt;/strong&gt;. That's the whole cue sheet. You produce it once, by listening with a waveform editor open, and it never changes unless the recording does.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

struct Verse: Decodable {
    let id: Int
    let kind: String
    let start: TimeInterval
    let text: String
}

struct Recitation: Decodable {
    let title: String
    let audio: String
    let verses: [Verse]

    static func loadBundled(named name: String = &quot;chalisa&quot;) throws -&amp;gt; Recitation {
        guard let url = Bundle.main.url(forResource: name, withExtension: &quot;json&quot;) else {
            throw CocoaError(.fileNoSuchFile)
        }
        let recitation = try JSONDecoder().decode(Recitation.self, from: Data(contentsOf: url))
        // Cue sheets must be sorted; fail loudly in debug if someone edits one by hand.
        assert(zip(recitation.verses, recitation.verses.dropFirst()).allSatisfy { $0.start &amp;lt; $1.start })
        return recitation
    }

    /// Index of the verse being recited at `time` (binary search over start times).
    func verseIndex(at time: TimeInterval) -&amp;gt; Int {
        var low = 0, high = verses.count - 1, answer = 0
        while low &amp;lt;= high {
            let mid = (low + high) / 2
            if verses[mid].start &amp;lt;= time { answer = mid; low = mid + 1 } else { high = mid - 1 }
        }
        return answer
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few practical notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AAC (&lt;code&gt;.m4a&lt;/code&gt;) is the right format&lt;/strong&gt; for a bundled spoken or sung recording: small, and decoded in hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the text as Unicode text, not images.&lt;/strong&gt; It scales with Dynamic Type, it works with VoiceOver, and you can offer other scripts later from the same structure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Binary search is overkill for 40-odd verses&lt;/strong&gt;, but it costs nothing, and the same code works for a 300-chapter audiobook.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-avaudioplayer-and-a-session-that-keeps-playing&quot;&gt;3. AVAudioPlayer and a session that keeps playing&lt;/h3&gt;
&lt;p&gt;For a single bundled file, &lt;code&gt;AVAudioPlayer&lt;/code&gt; (available since iOS 2.2) is all you need. There's no engine graph and nothing to stream. What matters is the &lt;strong&gt;audio session category&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class RecitationPlayer: NSObject, AVAudioPlayerDelegate {
    private(set) var player: AVAudioPlayer?
    var onFinish: (() -&amp;gt; Void)?

    func load(_ recitation: Recitation) throws {
        guard let url = Bundle.main.url(forResource: recitation.audio, withExtension: nil) else {
            throw CocoaError(.fileNoSuchFile)
        }
        let player = try AVAudioPlayer(contentsOf: url)
        player.delegate = self
        player.enableRate = true          // must be set before prepareToPlay() to allow rate changes
        player.prepareToPlay()
        self.player = player
    }

    func play() throws {
        let session = AVAudioSession.sharedInstance()
        // .playback: audio is central to the app; it plays with the Silent switch on
        // and, with the &quot;audio&quot; background mode, keeps going when the screen locks.
        try session.setCategory(.playback, mode: .spokenAudio, options: [])
        try session.setActive(true)
        player?.play()
    }

    func pause() { player?.pause() }

    func seek(to time: TimeInterval) {
        guard let player else { return }
        player.currentTime = max(0, min(time, player.duration))
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        onFinish?()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details that matter more than they look:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.playback&lt;/code&gt;, not the default.&lt;/strong&gt; The default category (&lt;code&gt;.soloAmbient&lt;/code&gt;) is silenced by the ring/silent switch and stops when the screen locks. For a prayer app, both are bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add the background mode.&lt;/strong&gt; In Xcode, enable Background Modes → &quot;Audio, AirPlay, and Picture in Picture&quot; (&lt;code&gt;audio&lt;/code&gt; in &lt;code&gt;UIBackgroundModes&lt;/code&gt;). Without it, &lt;code&gt;.playback&lt;/code&gt; alone won't keep you alive after the screen locks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;.spokenAudio&lt;/code&gt; mode&lt;/strong&gt; (iOS 9+) is meant for continuous spoken audio. Per Apple's docs, your audio &lt;em&gt;pauses&lt;/em&gt; rather than ducks when another app plays a short spoken prompt, such as a navigation instruction, and you can resume afterwards. A sung recitation sits somewhere between speech and music, so &lt;code&gt;.default&lt;/code&gt; is a reasonable choice too. &lt;code&gt;setCategory(_:mode:options:)&lt;/code&gt; is iOS 10+, so it's safe on an iOS 11 target.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-highlighting-the-current-verse&quot;&gt;4. Highlighting the current verse&lt;/h3&gt;
&lt;p&gt;To keep the text in step with the audio, poll &lt;code&gt;currentTime&lt;/code&gt; a few times a second and move the highlight when the verse index changes. On iOS 11 I use a &lt;code&gt;CADisplayLink&lt;/code&gt; (or a plain &lt;code&gt;Timer&lt;/code&gt;) rather than anything newer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UIKit

final class RecitationViewController: UITableViewController {
    private let recitation: Recitation
    private let audio = RecitationPlayer()
    private var link: CADisplayLink?
    private var currentIndex = -1

    init(recitation: Recitation) {
        self.recitation = recitation
        super.init(style: .plain)
    }
    required init?(coder: NSCoder) { fatalError(&quot;init(coder:) is not supported&quot;) }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: &quot;verse&quot;)
        tableView.rowHeight = UITableView.automaticDimension
        try? audio.load(recitation)
    }

    func startPlayback() {
        try? audio.play()
        link = CADisplayLink(target: self, selector: #selector(tick))
        link?.preferredFramesPerSecond = 10     // plenty for text; saves battery
        link?.add(to: .main, forMode: .common)
    }

    func stopUpdates() {
        link?.invalidate(); link = nil
    }

    @objc private func tick() {
        guard let time = audio.player?.currentTime else { return }
        let index = recitation.verseIndex(at: time)
        guard index != currentIndex else { return }
        let previous = currentIndex
        currentIndex = index
        let rows = [previous, index].filter { $0 &amp;gt;= 0 }.map { IndexPath(row: $0, section: 0) }
        tableView.reloadRows(at: rows, with: .fade)
        tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
    }

    // Tap a verse to jump the audio there.
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        audio.seek(to: recitation.verses[indexPath.row].start)
        tableView.deselectRow(at: indexPath, animated: true)
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -&amp;gt; Int {
        recitation.verses.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&amp;gt; UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: &quot;verse&quot;, for: indexPath)
        let isCurrent = indexPath.row == currentIndex
        cell.textLabel?.text = recitation.verses[indexPath.row].text
        cell.textLabel?.numberOfLines = 0
        cell.textLabel?.font = UIFont.preferredFont(forTextStyle: isCurrent ? .headline : .body)
        cell.textLabel?.adjustsFontForContentSizeCategory = true
        cell.backgroundColor = isCurrent ? UIColor.orange.withAlphaComponent(0.15) : .clear
        return cell
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two accessibility points that matter especially for this audience:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dynamic Type everywhere&lt;/strong&gt; (&lt;code&gt;preferredFont(forTextStyle:)&lt;/code&gt; with &lt;code&gt;adjustsFontForContentSizeCategory&lt;/code&gt;). Many people reading a prayer app use larger text sizes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't fight the user's scrolling.&lt;/strong&gt; If they've scrolled away to read ahead, pause auto-scrolling for a few seconds. Detect it in &lt;code&gt;scrollViewWillBeginDragging(_:)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Stop the display link when the view disappears or playback ends. A display link running in the background is wasted battery.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-lock-screen-and-control-center&quot;&gt;5. Lock Screen and Control Center&lt;/h3&gt;
&lt;p&gt;Once you're a real &lt;code&gt;.playback&lt;/code&gt; app, people expect to control it from the Lock Screen, Control Center, headphones and the car. That's two APIs: &lt;code&gt;MPNowPlayingInfoCenter&lt;/code&gt; (iOS 5+) for &lt;em&gt;what's playing&lt;/em&gt; and &lt;code&gt;MPRemoteCommandCenter&lt;/code&gt; (iOS 7.1+) for &lt;em&gt;what the buttons do&lt;/em&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import MediaPlayer
import UIKit

final class NowPlayingController {
    private weak var audio: RecitationPlayer?

    init(audio: RecitationPlayer, title: String, artwork: UIImage?) {
        self.audio = audio
        configureCommands()
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: title,
            MPMediaItemPropertyArtist: &quot;Recitation&quot;,
            MPMediaItemPropertyPlaybackDuration: audio.player?.duration ?? 0
        ]
        if let artwork {
            info[MPMediaItemPropertyArtwork] =
                MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork }
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    /// Call on play, pause and seek, not every frame. The system extrapolates
    /// elapsed time from the rate you give it.
    func update() {
        guard let player = audio?.player else { return }
        var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
        info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime
        info[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? 1.0 : 0.0
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    private func configureCommands() {
        let center = MPRemoteCommandCenter.shared()

        center.playCommand.addTarget { [weak self] _ in
            try? self?.audio?.play(); self?.update(); return .success
        }
        center.pauseCommand.addTarget { [weak self] _ in
            self?.audio?.pause(); self?.update(); return .success
        }
        center.togglePlayPauseCommand.addTarget { [weak self] _ in
            guard let player = self?.audio?.player else { return .commandFailed }
            if player.isPlaying { self?.audio?.pause() } else { try? self?.audio?.play() }
            self?.update(); return .success
        }
        center.changePlaybackPositionCommand.addTarget { [weak self] event in
            guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
            self?.audio?.seek(to: e.positionTime); self?.update(); return .success
        }
        // Commands you don't support should be disabled, so the UI doesn't show dead buttons.
        center.nextTrackCommand.isEnabled = false
        center.previousTrackCommand.isEnabled = false
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MPMediaItemArtwork(boundsSize:requestHandler:)&lt;/code&gt; is iOS 10+, so it's fine on iOS 11. The scrubber on the Lock Screen needs &lt;code&gt;changePlaybackPositionCommand&lt;/code&gt; (iOS 9.1+); without it, the scrubber is display-only.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't update Now Playing on every tick.&lt;/strong&gt; Set elapsed time and rate on play, pause and seek, and the system animates the progress itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-interruptions-and-route-changes-1&quot;&gt;6. Interruptions and route changes&lt;/h3&gt;
&lt;p&gt;Real phones ring. Here are the two notifications every audio app must handle:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class AudioInterruptionHandler {
    private weak var audio: RecitationPlayer?
    private var tokens: [NSObjectProtocol] = []
    private var wasPlaying = false
    var onChange: (() -&amp;gt; Void)?

    init(audio: RecitationPlayer) {
        self.audio = audio
        let center = NotificationCenter.default
        let session = AVAudioSession.sharedInstance()

        tokens.append(center.addObserver(forName: AVAudioSession.interruptionNotification,
                                         object: session, queue: .main) { [weak self] note in
            self?.handleInterruption(note)
        })
        tokens.append(center.addObserver(forName: AVAudioSession.routeChangeNotification,
                                         object: session, queue: .main) { [weak self] note in
            self?.handleRouteChange(note)
        })
    }

    deinit { tokens.forEach(NotificationCenter.default.removeObserver) }

    private func handleInterruption(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
        switch type {
        case .began:
            wasPlaying = audio?.player?.isPlaying ?? false   // the system has already paused us
        case .ended:
            let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            if wasPlaying, AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume) {
                try? audio?.play()
            }
            wasPlaying = false
        @unknown default: break
        }
        onChange?()
    }

    private func handleRouteChange(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
        // Headphones unplugged / Bluetooth gone: pause, as Apple recommends for media apps,
        // instead of suddenly reciting out loud on the speaker.
        if reason == .oldDeviceUnavailable { audio?.pause(); onChange?() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two version notes. The Swift names &lt;code&gt;AVAudioSession.interruptionNotification&lt;/code&gt; and &lt;code&gt;routeChangeNotification&lt;/code&gt; are the modern spellings; on the very oldest SDKs they were &lt;code&gt;NSNotification.Name.AVAudioSessionInterruption&lt;/code&gt; and &lt;code&gt;AVAudioSessionRouteChange&lt;/code&gt;. And Apple's current docs mark &lt;code&gt;.shouldResume&lt;/code&gt; as deprecated in iOS 27 in favour of a newer resumption-recommendation mechanism. It still works on every version this app supports, so I've kept it here.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-real-world-example-hanuman-chalisa-offline&quot;&gt;7. Real-world example: Hanuman Chalisa : Offline&lt;/h3&gt;
&lt;p&gt;This is the approach I'd use for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&quot;&gt;Hanuman Chalisa : Offline&lt;/a&gt;&lt;/strong&gt;, and for any text-plus-audio app.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-screenshot-1.jpg&quot; alt=&quot;Hanuman Chalisa App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in the &lt;strong&gt;Book&lt;/strong&gt; and &lt;strong&gt;Music&lt;/strong&gt; categories&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;April 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;22 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.8 stars&lt;/strong&gt;, the highest average among my apps with ten or more ratings&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 11.0 and later&lt;/strong&gt;, on iPhone, iPad and iPod touch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing describes the content simply: &quot;This version has both Chalisa in English Language Script (for reading) and Audio.&quot; Here's how that maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Hanuman Chalisa : Offline&quot; &lt;/td&gt;&lt;td&gt;Text, cue sheet and audio all bundled; no network code at all (section 2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Chalisa in English Language Script (for reading)&quot; &lt;/td&gt;&lt;td&gt;Unicode text with Dynamic Type in a table view (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;…and Audio&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AVAudioPlayer&lt;/code&gt; with a &lt;code&gt;.playback&lt;/code&gt; session and background mode (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;The iPhone, iPod &amp;amp; iPad version&quot; &lt;/td&gt;&lt;td&gt;UIKit-first code on long-standing APIs, iOS 11 and up (throughout)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-screenshot-2.jpg&quot; alt=&quot;Hanuman Chalisa App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;I later built a much larger devotional app, &lt;strong&gt;Mandir Darshan&lt;/strong&gt;, with live temple streams, the Bhagavad Gita and a chalisa collection. That one's a very different technical story, built on live streaming and picture-in-picture, and I'm covering it in its own article.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-before-you-ship-a-text-plus-audio-app-a-checklist&quot;&gt;8. Before you ship a text-plus-audio app: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It works in Airplane Mode from a fresh install&lt;/strong&gt;: text, audio and highlighting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The category is &lt;code&gt;.playback&lt;/code&gt;&lt;/strong&gt; and the &lt;code&gt;audio&lt;/code&gt; background mode is enabled, so audio survives the Silent switch and the Lock Screen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cue sheet is validated.&lt;/strong&gt; Start times are sorted, and every verse has one. Assert it in debug builds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tapping a verse seeks the audio&lt;/strong&gt;, and the highlight follows the audio, never the other way round.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock-screen controls work&lt;/strong&gt;, including the scrubber, and unsupported commands are disabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A phone call pauses playback&lt;/strong&gt;, and it resumes only if it was playing and the system says it may.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unplugging headphones pauses playback.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text respects Dynamic Type and VoiceOver.&lt;/strong&gt; Test at the largest accessibility sizes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is deactivated when playback finishes&lt;/strong&gt;, with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt;, so other audio can resume.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;A text-plus-audio app looks simple, and it can be, as long as you treat the audio session with respect. Bundle everything, describe timing with a tiny cue sheet, let &lt;code&gt;AVAudioPlayer&lt;/code&gt; play, keep the highlight driven by &lt;code&gt;currentTime&lt;/code&gt;, and give the system what it needs for the Lock Screen and for interruptions. It's well-established API, which is exactly why it still runs on iOS 11.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hanuman Chalisa : Offline (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&quot;&gt;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Have you built synchronised text and audio for lyrics, language lessons or audiobooks? I'd love to hear how you produced your timings. Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&quot;&gt;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32186/text-plus-audio-fully-offline-building-prayer-app-with-avaudioplayer-lock-screen-controls</guid>
<pubDate>Fri, 23 Oct 2026 16:38:02 +0000</pubDate>
</item>
<item>
<title>A Practical QA Checklist for Browser-Based Song Search</title>
<link>https://coderlegion.com/32152/a-practical-qa-checklist-for-browser-based-song-search</link>
<description>&lt;p&gt;Disclosure: I work on Hum to Search. This is a practical input-quality and review checklist, not an accuracy benchmark or a description of our private recognition backend.&lt;/p&gt;
&lt;p&gt;When a user remembers a melody but not a title, a search box is not always enough. A browser-based song search needs a different starting point: determine what kind of evidence the user actually has.&lt;/p&gt;
&lt;p&gt;Our &lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;song finder&lt;/a&gt; presents separate &quot;Hum or sing&quot; and &quot;Identify a recording&quot; modes. That distinction is useful when designing a test plan. A person humming a chorus and a recording of the original song are different inputs; a good interface should not make the user guess which path to choose.&lt;/p&gt;
&lt;h3 id=&quot;start-with-an-input-checklist&quot;&gt;Start with an input checklist&lt;/h3&gt;
&lt;p&gt;Before searching, answer three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is this a hummed melody, singing, or a recording of music playing elsewhere?&lt;/li&gt;
&lt;li&gt;Is the useful section audible, or is most of the clip silence or conversation?&lt;/li&gt;
&lt;li&gt;Can the person record another attempt without replaying the same weak sample?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For humming, the current input screen suggests 10-20 seconds in a quiet place and trying the chorus without background music. Treat that as capture guidance, not a promise of a match. A clear, continuous phrase is a better test input than several disconnected fragments.&lt;/p&gt;
&lt;p&gt;For a recording, keep a short section where the music itself is audible. Do not upload unrelated conversations or other people's private audio just to identify background music.&lt;/p&gt;
&lt;h3 id=&quot;separate-capture-problems-from-recognition-problems&quot;&gt;Separate capture problems from recognition problems&lt;/h3&gt;
&lt;p&gt;A failed result can mean several things. The microphone may not have captured sound, the recording may be unclear, or the supplied evidence may not produce a useful candidate. These should be different states in a product test report.&lt;/p&gt;
&lt;p&gt;Here is a small QA matrix a developer can adapt:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test input &lt;/th&gt;&lt;th&gt;What to inspect &lt;/th&gt;&lt;th&gt;Useful next action&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Microphone access declined &lt;/td&gt;&lt;td&gt;Is the reason visible? &lt;/td&gt;&lt;td&gt;Explain how to retry or choose upload&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Silent recording &lt;/td&gt;&lt;td&gt;Is there usable audio? &lt;/td&gt;&lt;td&gt;Record again before another search&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hummed chorus &lt;/td&gt;&lt;td&gt;Correct input mode and continuous melody &lt;/td&gt;&lt;td&gt;Compare a different clear phrase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Music with speech over it &lt;/td&gt;&lt;td&gt;Whether music is actually audible &lt;/td&gt;&lt;td&gt;Choose a cleaner section&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ambiguous candidate &lt;/td&gt;&lt;td&gt;Title, artist and familiar melody &lt;/td&gt;&lt;td&gt;Listen and verify rather than accept blindly&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This is a proposed test matrix. It does not claim that every browser or every case has passed testing on our site.&lt;/p&gt;
&lt;h3 id=&quot;keep-a-lightweight-test-log&quot;&gt;Keep a lightweight test log&lt;/h3&gt;
&lt;p&gt;For a reproducible evaluation, write down the conditions rather than just saying &quot;it worked&quot; or &quot;it failed&quot;. A simple CSV is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csv&quot;&gt;case_id,input_mode,clip_seconds,environment,expected_song,result,review_note
hum-01,hum,15,quiet,known_test_track,pending,compare chorus
record-01,recording,12,background_speech,known_test_track,pending,try cleaner section
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are example rows, not measured results. Use audio you own or have permission to test. Keep identifying information out of shared logs, and set your own retention policy before keeping recordings.&lt;/p&gt;
&lt;p&gt;Change one variable at a time. If the first attempt is noisy, improve the recording before changing the melody, device and duration together. Otherwise you will not know which change helped.&lt;/p&gt;
&lt;h3 id=&quot;a-candidate-still-needs-a-human-check&quot;&gt;A candidate still needs a human check&lt;/h3&gt;
&lt;p&gt;A returned title is a candidate to investigate, not a reason to stop thinking. Listen to a legitimate preview and compare the part you remember. Covers, live recordings and similar melodies can make a result feel familiar without being the exact track you wanted.&lt;/p&gt;
&lt;p&gt;For product design, &quot;no useful match&quot; deserves its own helpful recovery path. Suggest a clearer phrase or a different input mode instead of repeating a generic error. If the service uses credits, show the current cost before the user submits; avoid promising unlimited retries unless the actual plan supports them.&lt;/p&gt;
&lt;h3 id=&quot;try-the-workflow&quot;&gt;Try the workflow&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;Hum to Search&lt;/a&gt; is our browser-based starting point for this workflow. Choose the appropriate input mode, provide a clear sample, and review the candidate instead of assuming recognition is certain. Check the live interface and pricing page for current usage conditions.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;https://humtosearch.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For developers building an audio interface, the practical takeaway is simple: make input type explicit, make failures distinguishable, and make the next attempt easier to understand.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32152/a-practical-qa-checklist-for-browser-based-song-search</guid>
<pubDate>Fri, 23 Oct 2026 07:38:41 +0000</pubDate>
</item>
<item>
<title>Three audio checks worth running before you ship any upload feature</title>
<link>https://coderlegion.com/31333/three-audio-checks-worth-running-before-you-ship-any-upload-feature</link>
<description>&lt;p&gt;Most &quot;the audio is broken&quot; reports are not DSP bugs. They are format and metadata bugs that surface weeks later, after the feature has already shipped. These three checks are cheap and catch the majority of them.&lt;/p&gt;
&lt;h3 id=&quot;1-read-the-header-not-the-extension&quot;&gt;1. Read the header, not the extension&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;.wav&lt;/code&gt; can be 8-bit PCM, 24-bit float, or something a recorder invented. If you branch on the file extension you will eventually hand a decoder something it silently mangles. Parse the actual header first, then decide what to do with it.&lt;/p&gt;
&lt;h3 id=&quot;2-sample-rate-mismatches-are-silent&quot;&gt;2. Sample rate mismatches are silent&lt;/h3&gt;
&lt;p&gt;Drop a 44.1 kHz source into a 48 kHz graph and nothing throws. It just plays back at the wrong speed and pitch. Normalise on ingest, then assert the rate at every stage boundary. An assertion costs nothing; a support ticket does not.&lt;/p&gt;
&lt;h3 id=&quot;3-peak-level-is-not-loudness&quot;&gt;3. Peak level is not loudness&lt;/h3&gt;
&lt;p&gt;A track can peak at 0 dBFS and still sound quiet next to its neighbours. If your UI says &quot;loud&quot; or &quot;quiet&quot;, you are measuring loudness (LUFS), not peaks. Pick one metric, name it in the UI, and stop guessing which one you meant.&lt;/p&gt;
&lt;p&gt;None of this needs a DSP background. It needs one explicit place in the pipeline where the invariant is written down and checked.&lt;/p&gt;
&lt;p&gt;These three came straight out of debugging the upload path on &lt;a rel=&quot;nofollow&quot; href=&quot;https://creatune.ai/&quot;&gt;Creatune AI Music Generator&lt;/a&gt;, which I work on — so treat the recommendation with the usual scepticism.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31333/three-audio-checks-worth-running-before-you-ship-any-upload-feature</guid>
<pubDate>Fri, 16 Oct 2026 13:08:03 +0000</pubDate>
</item>
<item>
<title>Kempo: Real-Time Audio Time-Stretching, Pitch-Shifting, and Formant Control in Pure KMP</title>
<link>https://coderlegion.com/31021/kempo-real-time-audio-time-stretching-pitch-shifting-and-formant-control-in-pure-kmp</link>
<description>&lt;h4 id=&quot;zero-allocation-streaming-phase-locked-clarity-and-multichannel-spatial-coherence-across-jvm-android-ios-and-webassembly-&quot;&gt;Zero-allocation streaming, phase-locked clarity, and multichannel spatial coherence across JVM, Android, iOS, and WebAssembly.&lt;/h4&gt;
&lt;p&gt;Whether you are building a podcast player with variable playback speed, a music practice app that slows down a complex guitar solo without dropping the tuning, a real-time voice modulator, or a spatial audio engine for a game, you eventually hit the same fundamental challenge: &lt;strong&gt;decoupling time from pitch&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In raw digital audio, playback speed and pitch are locked together. Play a buffer twice as fast, and the pitch jumps up a full octave. Slow it down, and everything drags into a muddy baritone.&lt;/p&gt;
&lt;p&gt;Breaking that link—while keeping drums punchy, vocals natural, and the stereo image intact—requires a dedicated Digital Signal Processing (DSP) engine.&lt;/p&gt;
&lt;p&gt;Meet &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kempo&quot;&gt;Kempo&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;io.github.numq:kempo&lt;/code&gt;): an open-source, pure Kotlin Multiplatform audio time-stretching and pitch-shifting library built for studio-grade acoustic transparency and sub-millisecond real-time streaming across &lt;strong&gt;JVM, Android, iOS, and WebAssembly (Wasm/JS)&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-kempo-brings-to-your-audio-stack&quot;&gt;What Kempo Brings to Your Audio Stack&lt;/h3&gt;
&lt;p&gt;Kempo is designed to be drop-in simple for high-level application code while exposing full, granular control for low-level audio threads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Independent Time, Pitch, and Formant Control:&lt;/strong&gt; Speed up or slow down audio (&lt;code&gt;0.25x&lt;/code&gt; to &lt;code&gt;3.0x&lt;/code&gt; and beyond), transpose musical pitch in semitones, and shift vocal timbre independently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero-Allocation Real-Time Streaming:&lt;/strong&gt; Once configured, the block-by-block processing loop allocates &lt;strong&gt;0 bytes on the heap&lt;/strong&gt;, eliminating Garbage Collection pauses on high-priority audio threads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertical Harmonic Phase-Locking:&lt;/strong&gt; Keeps transients crisp and eliminates the hollow, &quot;underwater&quot; phasing artifacts common in basic time-stretchers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multichannel &amp;amp; 5.1 Surround Coherence:&lt;/strong&gt; Preserves exact inter-channel phase relationships across stereo and surround tracks so the soundstage never collapses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sample-Accurate Offline Rendering:&lt;/strong&gt; Built-in pre-roll warm-up, algorithmic latency compensation, and automatic overlap-add tail flushing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built-In PCM-16 Serialization:&lt;/strong&gt; Convert raw little-endian or big-endian WAV/PCM byte arrays directly into normalized floating-point tracks and back.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-kempo-keeps-audio-crisp-without-the-artifacts-&quot;&gt;How Kempo Keeps Audio Crisp (Without the Artifacts)&lt;/h3&gt;
&lt;p&gt;If you have ever used a naive time-stretching algorithm, you probably recognize three classic audio defects: smeared drum hits, &quot;chipmunk&quot; vocals, and a collapsing stereo field. Kempo solves all three at the engine level.&lt;/p&gt;
&lt;h4 id=&quot;1-eliminating-transient-smearing-with-vertical-phase-locking&quot;&gt;1. Eliminating Transient Smearing with Vertical Phase-Locking&lt;/h4&gt;
&lt;p&gt;Standard phase vocoders split audio into hundreds of narrow frequency bins and advance the phase of each bin over time. Because each bin is updated in isolation, the natural harmonic alignment between a fundamental note and its overtones quickly drifts apart. Percussive attacks lose their snap, and instruments sound phasey and diffuse.&lt;/p&gt;
&lt;p&gt;Kempo continuously analyzes the smoothed spectral energy contour of each frame to locate prominent spectral peaks. Instead of letting neighboring bins drift, Kempo performs &lt;strong&gt;vertical phase-locking&lt;/strong&gt;—anchoring the phase trajectories of surrounding frequency bins to their dominant harmonic peaks. Harmonics move together as a unified acoustic event, keeping transients tight.&lt;/p&gt;
&lt;p&gt;When stretching audio to extreme lengths (beyond &lt;code&gt;2.0x&lt;/code&gt;), Kempo automatically transitions into controlled stochastic phase dispersion, preventing the metallic ringing that usually plagues ultra-slow playback.&lt;/p&gt;
&lt;h4 id=&quot;2-fixing-the-chipmunk-effect-with-formant-decoupling&quot;&gt;2. Fixing the &quot;Chipmunk&quot; Effect with Formant Decoupling&lt;/h4&gt;
&lt;p&gt;When you pitch a human voice up by +5 semitones, you aren't just raising the pitch of the vocal cords—you are also shrinking the perceived size of the singer's vocal tract. The result is the notorious &quot;chipmunk&quot; effect (or the &quot;monster&quot; effect when pitching down).&lt;/p&gt;
&lt;p&gt;In Kempo, pitch and vocal timbre (formants) are decoupled. Kempo extracts the spectral envelope of the signal and allows you to shift or lock formants independently of the musical transposition:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Natural Pitch Shifting:&lt;/strong&gt; Enable formant compensation (&lt;code&gt;compensatePitch = true&lt;/code&gt;) to keep a singer's vocal tract resonance anchored while changing their melody.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Creative Voice Design:&lt;/strong&gt; Shift formants up or down while keeping pitch at &lt;code&gt;0 st&lt;/code&gt; to transform the character and age of a voice without changing the note being sung.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-preserving-stereo-width-and-5-1-surround-imaging&quot;&gt;3. Preserving Stereo Width and 5.1 Surround Imaging&lt;/h4&gt;
&lt;p&gt;Our ears localize sound in space by detecting tiny timing and phase differences between the left and right channels. If an audio processor stretches the Left and Right channels independently, their relative phases drift apart—causing the stereo image to wobble and center-panned vocals to hollow out when downmixed.&lt;/p&gt;
&lt;p&gt;Kempo processes multichannel audio coherently. At every frequency band, it identifies the dominant channel and locks the Inter-channel Phase Differences (IPD) of all remaining channels to that reference. Whether you are processing a stereo master or a discrete 6-channel 5.1 surround mix, panning stays locked and center dialogue remains phase-aligned.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h3&gt;
&lt;p&gt;Kempo is published on Maven Central. Add the dependency to your &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(&quot;io.github.numq:kempo:1.0.0&quot;)
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or for a single-platform JVM/Android module:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;dependencies {
    implementation(&quot;io.github.numq:kempo:1.0.0&quot;)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;1-the-one-liner-offline-track-processing&quot;&gt;1. The One-Liner: Offline Track Processing&lt;/h4&gt;
&lt;p&gt;If you have raw 16-bit PCM bytes (from a WAV file, network stream, or microphone recording) and want to stretch or transpose them in a background coroutine, &lt;code&gt;PcmConverter&lt;/code&gt; and &lt;code&gt;AudioTrack&lt;/code&gt; handle the entire pipeline in a few lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.PcmConverter

// 1. Decode interleaved 16-bit PCM bytes into a normalized [-1.0f, 1.0f] AudioTrack
val track = PcmConverter.fromPcm16(
    bytes = rawWavBytes,
    channels = 2,
    sampleRate = 44100f,
    isLittleEndian = true
)

// 2. Speed up by 25%, pitch up by 3 semitones, keep natural formants
val stretchedTrack = track.stretch(
    timeRatio = 1.25f,
    pitchSemitones = 3.0f,
    formantSemitones = 0.0f
)

// 3. Convert back to 16-bit PCM bytes for playback or file export
val outputBytes = PcmConverter.toPcm16(stretchedTrack, isLittleEndian = true)

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-zero-allocation-real-time-streaming&quot;&gt;2. Zero-Allocation Real-Time Streaming&lt;/h4&gt;
&lt;p&gt;Real-time audio callbacks operate under strict deadlines. Allocating arrays or objects inside the render loop will eventually trigger the garbage collector and cause audible clicks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KempoStretch&lt;/code&gt; pre-allocates all ring buffers, FFT plans, twiddle factors, and spectral workspaces upfront during &lt;code&gt;presetDefault()&lt;/code&gt; or &lt;code&gt;configure()&lt;/code&gt;. Inside your audio callback, &lt;code&gt;process()&lt;/code&gt; mutates pre-allocated buffers in place:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.KempoStretch
import kotlin.math.max

class LiveAudioProcessor(
    channels: Int = 2,
    sampleRate: Float = 48000f
) {
    private val stretch = KempoStretch().apply {
        presetDefault(channels, sampleRate)
    }

    val outBlockSize: Int = stretch.defaultInterval
    private val outBuffer = Array(channels) { FloatArray(outBlockSize) }

    fun updateParameters(pitchSemitones: Float, formantSemitones: Float) {
        stretch.setTransposeSemitones(pitchSemitones)
        stretch.setFormantSemitones(formantSemitones, compensatePitch = true)
    }

    // Safe for high-priority audio threads: 0 B heap allocations
    fun processFrame(
        inputChannels: Array&amp;lt;FloatArray&amp;gt;,
        playbackSpeed: Float
    ): Array&amp;lt;FloatArray&amp;gt; {
        val inSamples = max(1, (outBlockSize * playbackSpeed).toInt())
        stretch.process(
            inputs = inputChannels,
            inputSamples = inSamples,
            outputs = outBuffer,
            outputSamples = outBlockSize
        )
        return outBuffer
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Need to support interactive scrubbing on a timeline without clicks or phase resets? Use &lt;code&gt;stretch.seek()&lt;/code&gt; before resuming playback to pre-seed the internal phase accumulators with the audio frames right before the new cursor position:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val seekPreRoll = minOf(stretch.seekLength(), targetSampleIndex)
stretch.seek(
    inputs = track.channels,
    inputSamples = seekPreRoll,
    playbackRate = 1.0,
    inputOffset = (targetSampleIndex - seekPreRoll).coerceAtLeast(0)
)

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-sample-accurate-offline-rendering-with-exact-&quot;&gt;3. Sample-Accurate Offline Rendering with &lt;code&gt;exact()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;When rendering an audio clip for a DAW timeline or a game asset pipeline, you often need the output buffer to match an exact target sample count—without leading silence from the STFT window latency or cut-off reverb tails at the end.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KempoStretch.exact()&lt;/code&gt; automates the entire offline lifecycle: it calculates the required pre-roll (&lt;code&gt;outputSeekLength&lt;/code&gt;), warms up the vocoder state (&lt;code&gt;outputSeek&lt;/code&gt;), processes the body of the track, and flushes the final synthesis tails (&lt;code&gt;flush&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.KempoStretch
import kotlin.math.roundToInt

fun renderExactClip(
    input: Array&amp;lt;FloatArray&amp;gt;,
    sampleRate: Float,
    timeRatio: Float,
    pitchSemitones: Float
): Array&amp;lt;FloatArray&amp;gt; {
    val channels = input.size
    val inSamples = input[0].size
    val outSamples = (inSamples / timeRatio).roundToInt().coerceAtLeast(1)
    val output = Array(channels) { FloatArray(outSamples) }

    val stretch = KempoStretch().apply {
        presetDefault(channels, sampleRate)
        setTransposeSemitones(pitchSemitones)
    }

    stretch.exact(
        inputs = input,
        inputSamples = inSamples,
        outputs = output,
        outputSamples = outSamples
    )
    return output
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;4-custom-frequency-mapping-amp-tonality-limits&quot;&gt;4. Custom Frequency Mapping &amp;amp; Tonality Limits&lt;/h4&gt;
&lt;p&gt;For experimental sound design, spectral effects, or specialized speech conditioning, Kempo lets you bypass standard semitone math entirely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tonality Limits:&lt;/strong&gt; When transposing speech or vocals up, multiplying high-frequency sibilance (&quot;s&quot; and &quot;sh&quot; sounds) can make them sound lispy or artificial. Passing a &lt;code&gt;tonalityLimit&lt;/code&gt; to &lt;code&gt;setTransposeSemitones(semitones, tonalityLimit)&lt;/code&gt; transitions the upper spectrum from multiplicative harmonic scaling to a clean linear shift.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom Transfer Functions:&lt;/strong&gt; Pass any lambda to &lt;code&gt;setFreqMap&lt;/code&gt; to warp normalized frequencies &lt;code&gt;[0.0f, 0.5f]&lt;/code&gt; non-linearly:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;stretch.setFreqMap { normalizedFreq -&amp;gt;
    // Custom non-linear spectral warping
    (normalizedFreq * 1.15f).coerceIn(0f, 0.5f)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;engineered-for-speed-in-pure-kotlin&quot;&gt;Engineered for Speed in Pure Kotlin&lt;/h3&gt;
&lt;p&gt;Writing high-throughput DSP in pure Kotlin without falling back to platform-specific C/C++ libraries requires careful attention to memory layout and CPU cache locality:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flat Power-of-Two Ring Buffers (&lt;code&gt;MultiChannelBuffer&lt;/code&gt;):&lt;/strong&gt; Instead of nested object arrays or modulo arithmetic, Kempo backs all channels with a single flat &lt;code&gt;FloatArray&lt;/code&gt;. Wrap-around indices are resolved via bitwise masks (&lt;code&gt;(head + offset) and mask&lt;/code&gt;), and overlap-add accumulation uses 4x unrolled loops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-Place Mixed-Radix FFT (&lt;code&gt;FastFft&lt;/code&gt; &amp;amp; &lt;code&gt;ModifiedRealFFT&lt;/code&gt;):&lt;/strong&gt; Supports radix-2, radix-3, radix-4, and composite window sizes with precomputed twiddle tables. Real signals are evaluated on a half-bin shifted grid &lt;code&gt;(k + 0.5) / N&lt;/code&gt;, avoiding DC and Nyquist edge singularities and cutting transform overhead in half.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split Computation Mode (&lt;code&gt;split = true&lt;/code&gt;):&lt;/strong&gt; In real-time audio, doing a full STFT analysis and synthesis every &lt;code&gt;N&lt;/code&gt; samples can cause periodic CPU spikes. Enabling &lt;code&gt;split = true&lt;/code&gt; in &lt;code&gt;configure()&lt;/code&gt; or &lt;code&gt;presetCheaper()&lt;/code&gt; slices the spectral workload into small incremental steps distributed evenly across every sample frame.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic Silence Bypass:&lt;/strong&gt; When input energy drops below the noise floor, Kempo automatically bypasses heavy spectral math while keeping ring buffers synchronized.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In JMH benchmarks on standard desktop JVM hardware (44.1 kHz audio), Kempo processes a real-time streaming block in &lt;strong&gt;~220 μs (mono)&lt;/strong&gt; and &lt;strong&gt;~484 μs (stereo)&lt;/strong&gt;, and renders offline audio at &lt;strong&gt;over 128x real-time speed&lt;/strong&gt; for mono and &lt;strong&gt;~70x real-time&lt;/strong&gt; for stereo.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-live-the-compose-desktop-studio-app&quot;&gt;Try It Live: The Compose Desktop Studio App&lt;/h3&gt;
&lt;p&gt;Want to hear how Kempo handles your own audio files before writing a line of code?&lt;/p&gt;
&lt;p&gt;The repository includes an interactive &lt;strong&gt;Compose Multiplatform Desktop Studio&lt;/strong&gt; application (&lt;code&gt;:example&lt;/code&gt;). Drop any &lt;code&gt;.wav&lt;/code&gt; file onto the window to inspect its waveform, scrub through the timeline in real time, and tweak live parameters on the fly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Playback Speed (&lt;code&gt;0.25x&lt;/code&gt; – &lt;code&gt;3.00x&lt;/code&gt;)&lt;/strong&gt; with instant presets (&lt;code&gt;0.50x&lt;/code&gt;, &lt;code&gt;0.75x&lt;/code&gt;, &lt;code&gt;1.25x&lt;/code&gt;, &lt;code&gt;1.50x&lt;/code&gt;, &lt;code&gt;2.00x&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pitch Shift (&lt;code&gt;-12 st&lt;/code&gt; to &lt;code&gt;+12 st&lt;/code&gt;)&lt;/strong&gt; in semitone steps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formant Shift (&lt;code&gt;-12 st&lt;/code&gt; to &lt;code&gt;+12 st&lt;/code&gt;)&lt;/strong&gt; for real-time vocal timbre shaping&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;explore-the-code&quot;&gt;Explore the Code&lt;/h3&gt;
&lt;p&gt;Check out the full source code, architecture docs, and the runnable CLI attack simulation on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kempo&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31021/kempo-real-time-audio-time-stretching-pitch-shifting-and-formant-control-in-pure-kmp</guid>
<pubDate>Wed, 14 Oct 2026 07:01:23 +0000</pubDate>
</item>
<item>
<title>How AI Music Generators Turn a Text Prompt Into Audio</title>
<link>https://coderlegion.com/28553/how-ai-music-generators-turn-a-text-prompt-into-audio</link>
<description>&lt;p&gt;If you have only used AI music tools from the outside, the pipeline underneath is a nice piece of engineering that borrows heavily from the language models most of us already work with. This is a short tour of how text becomes a finished track, written for developers who want to know what is actually running.&lt;/p&gt;
&lt;h3 id=&quot;step-one-turn-audio-into-tokens&quot;&gt;Step One: Turn Audio Into Tokens&lt;/h3&gt;
&lt;p&gt;Raw audio is a continuous waveform with tens of thousands of samples per second, far too much for a language style model to predict directly. Neural codecs like Meta's EnCodec and Google's SoundStream compress it into sequences of discrete tokens, so a minute of audio becomes a few thousand tokens instead of millions of samples. Each token captures both what is playing and how it sounds, and that compression is what makes generation computationally feasible.&lt;/p&gt;
&lt;h3 id=&quot;step-two-generate-the-tokens&quot;&gt;Step Two: Generate the Tokens&lt;/h3&gt;
&lt;p&gt;Two architectures dominate. Autoregressive transformers, the approach behind Suno and Meta's MusicGen, predict one audio token at a time from everything that came before it, exactly like next word prediction in a chat model. They tend to be faster and better at holding long range structure, like a verse coming back after the chorus. Diffusion models, used by Stability AI's Stable Audio, start from noise and refine it over many steps, which often gives cleaner and more detailed textures.&lt;/p&gt;
&lt;h3 id=&quot;step-three-condition-on-the-prompt&quot;&gt;Step Three: Condition on the Prompt&lt;/h3&gt;
&lt;p&gt;To make &quot;upbeat jazz piano trio with walking bass&quot; mean something, the system uses a joint embedding model such as CLAP or MuLan. These are trained so that a text description and the matching audio land close together in the same vector space, and during generation the text embedding steers the model toward audio that fits. The final tokens are decoded back into a WAV or MP3, sometimes with a light mastering pass on top.&lt;/p&gt;
&lt;h3 id=&quot;running-it-yourself&quot;&gt;Running It Yourself&lt;/h3&gt;
&lt;p&gt;MusicGen and Stable Audio Open are both open models, so you can run the whole pipeline on your own GPU with no per track license terms. The hosted tools differ mostly in audio quality, vocals and licensing, and this &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.aitools9.com/ai-music-generator/&quot;&gt;breakdown of AI music generators&lt;/a&gt; covers how Suno, Udio, SOUNDRAW and the rest compare on those points.&lt;/p&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The Takeaway&lt;/h3&gt;
&lt;p&gt;Under the hood, an AI music generator is a codec, a sequence model and a text encoder wired together, the same building blocks as the language models we ship every day. If you have worked with embeddings and next token prediction, you already understand most of it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28553/how-ai-music-generators-turn-a-text-prompt-into-audio</guid>
<pubDate>Thu, 24 Sep 2026 23:09:20 +0000</pubDate>
</item>
<item>
<title>Why Voice Practice Improves When Recording and Evaluation Are Separate</title>
<link>https://coderlegion.com/25845/why-voice-practice-improves-when-recording-and-evaluation-are-separate</link>
<description>&lt;p&gt;A browser voice-practice tool looks simple from the outside: show a line, turn on the microphone, record a take, and play it back. In practice, the most important design decision is not the recorder API. It is deciding &lt;strong&gt;when the performer should perform and when they should evaluate&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Trying to do both at once creates unnecessary cognitive load. A user watches the script, remembers the character, controls timing, listens to their own delivery, and judges the result simultaneously. The result is usually a cautious performance and vague feedback: &quot;something felt off.&quot;&lt;/p&gt;
&lt;p&gt;A better workflow separates performance from evaluation.&lt;/p&gt;
&lt;h3 id=&quot;treat-recording-and-review-as-different-modes&quot;&gt;Treat recording and review as different modes&lt;/h3&gt;
&lt;p&gt;During recording, the interface should protect momentum. The user needs a clear prompt, a visible recording state, and one obvious way to stop. Metrics, waveforms, scores, and comparison controls can wait.&lt;/p&gt;
&lt;p&gt;During review, the interface can become analytical. The user is no longer trying to stay in character, so it is safe to expose playback controls, a reference track, retry options, and a small review checklist.&lt;/p&gt;
&lt;p&gt;This separation sounds minor, but it changes the user's mental model:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Perform the scene without self-editing.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Listen after the take is complete.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose one adjustment for the next take.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The third step matters. If a review screen produces ten suggestions, the next performance becomes overloaded again. One specific adjustment—slower pacing, a stronger final word, or a longer pause—is usually more actionable.&lt;/p&gt;
&lt;h3 id=&quot;model-the-experience-as-a-state-machine&quot;&gt;Model the experience as a state machine&lt;/h3&gt;
&lt;p&gt;Browser media tools become easier to reason about when the product has explicit states instead of a collection of loosely related booleans.&lt;/p&gt;
&lt;p&gt;A minimal session might use:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idle -&amp;gt; requesting_permission -&amp;gt; ready -&amp;gt; recording -&amp;gt; processing -&amp;gt; review
                                      ^                         |
                                      |-------- retry ----------|
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each state should have a single primary action. This prevents common interface contradictions such as showing both &quot;Record&quot; and &quot;Play&quot; while the browser is still finalizing a media blob.&lt;/p&gt;
&lt;p&gt;It also makes error recovery clearer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Permission denied returns to an explanatory state, not a dead recorder.&lt;/li&gt;
&lt;li&gt;A missing microphone produces a device-specific message.&lt;/li&gt;
&lt;li&gt;A failed encoding attempt keeps the original take whenever possible.&lt;/li&gt;
&lt;li&gt;A retry starts a new take without destroying the previous one until the new take is valid.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The implementation can use a reducer, a finite-state library, or a carefully typed union. The tool is less important than making invalid combinations impossible.&lt;/p&gt;
&lt;h3 id=&quot;keep-feedback-concrete-and-audible&quot;&gt;Keep feedback concrete and audible&lt;/h3&gt;
&lt;p&gt;Voice practice benefits from feedback that the user can hear rather than abstract scores alone. A numeric &quot;expressiveness&quot; value may feel precise, but it does not tell the performer what to change.&lt;/p&gt;
&lt;p&gt;Useful review controls include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Replay the user's take without visual distraction.&lt;/li&gt;
&lt;li&gt;Jump directly to the beginning of a line.&lt;/li&gt;
&lt;li&gt;Compare two takes at matched volume.&lt;/li&gt;
&lt;li&gt;Play a reference performance separately.&lt;/li&gt;
&lt;li&gt;Add a short note describing the next intentional change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A reference should be treated as an interpretation, not the answer. The goal is to help users notice pacing, emphasis, energy, and consistency while preserving their own performance choices.&lt;/p&gt;
&lt;h3 id=&quot;design-for-short-repeatable-loops&quot;&gt;Design for short, repeatable loops&lt;/h3&gt;
&lt;p&gt;Long recordings raise the cost of every mistake. Short scenes and clearly bounded lines make practice easier to repeat, especially for new users.&lt;/p&gt;
&lt;p&gt;A productive loop can be less than a minute:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the character cue.&lt;/li&gt;
&lt;li&gt;Record one short take.&lt;/li&gt;
&lt;li&gt;Replay it once.&lt;/li&gt;
&lt;li&gt;Select one change.&lt;/li&gt;
&lt;li&gt;Record the next take.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This loop also reduces technical risk. Shorter media blobs are faster to finalize, easier to retain locally, and less frustrating to retry after a device interruption.&lt;/p&gt;
&lt;p&gt;When experimenting with this pattern, I use &lt;a rel=&quot;nofollow&quot; href=&quot;https://choicervoicer.games/&quot;&gt;ChoicerVoicer&lt;/a&gt; as a practical browser-based example: the experience centers on performing voice challenges and reviewing the result, instead of presenting recording as an isolated utility.&lt;/p&gt;
&lt;h3 id=&quot;make-microphone-permission-part-of-the-product&quot;&gt;Make microphone permission part of the product&lt;/h3&gt;
&lt;p&gt;Permission prompts are often treated as browser plumbing, but they are a critical moment in the user journey. Asking for microphone access immediately on page load removes context and can reduce trust.&lt;/p&gt;
&lt;p&gt;A clearer sequence is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Explain why the microphone is needed.&lt;/li&gt;
&lt;li&gt;Let the user initiate the action.&lt;/li&gt;
&lt;li&gt;Request access in direct response to that action.&lt;/li&gt;
&lt;li&gt;Confirm the selected input device.&lt;/li&gt;
&lt;li&gt;Provide a path to recover if permission was denied.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The UI should never imply that audio is being recorded before the browser has actually granted access. Likewise, a glowing microphone icon should reflect a real recording state rather than an optimistic click state.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-user-s-sense-of-control&quot;&gt;Preserve the user's sense of control&lt;/h3&gt;
&lt;p&gt;Voice recordings feel personal. Even when a session is lightweight, the interface should say what is happening to the audio and give users a clear way to discard a take.&lt;/p&gt;
&lt;p&gt;Good defaults include keeping an unfinished take local, labeling uploads explicitly, and avoiding surprise sharing. If a recording will leave the device, that transition should be visible and intentional.&lt;/p&gt;
&lt;p&gt;This is also why the review screen should not automatically publish, score, or compare a take before the user knows the recording has ended successfully.&lt;/p&gt;
&lt;h3 id=&quot;the-product-lesson&quot;&gt;The product lesson&lt;/h3&gt;
&lt;p&gt;The strongest voice-practice experience is not the one with the most analysis on screen. It is the one that helps users alternate cleanly between two mindsets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Performance mode:&lt;/strong&gt; commit to the line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evaluation mode:&lt;/strong&gt; observe the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Iteration mode:&lt;/strong&gt; change one thing and try again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once these modes are explicit, both the interface and the code become easier to design. The recorder has fewer ambiguous states, feedback becomes more actionable, and the user can focus on performing instead of operating software.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25845/why-voice-practice-improves-when-recording-and-evaluation-are-separate</guid>
<pubDate>Sun, 30 Aug 2026 13:21:18 +0000</pubDate>
</item>
<item>
<title>Custom Audio Control on Linux: Switch Between Headphones, Speakers, and Dual Output with a Single Co</title>
<link>https://coderlegion.com/8660/custom-audio-control-linux-switch-between-headphones-speakers-and-dual-output-with-single</link>
<description>&lt;h2 id=&quot;custom-audio-control-on-linux-switch-between-headphones-speakers-and-dual-output-with-a-single-command&quot;&gt;Custom Audio Control on Linux: Switch Between Headphones, Speakers, and Dual Output with a Single Command&lt;/h2&gt;
&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Linux users know that when it comes to &lt;strong&gt;system-level control&lt;/strong&gt;, almost everything is possible — including building your own solutions for problems that graphical interfaces don’t solve well.&lt;/p&gt;
&lt;p&gt;In this article, I introduce &lt;strong&gt;&lt;code&gt;audioctl&lt;/code&gt;&lt;/strong&gt;, a small &lt;strong&gt;Bash script&lt;/strong&gt; that allows you to switch, with a &lt;strong&gt;single command&lt;/strong&gt;, between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Headphones connected via &lt;strong&gt;HDMI&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Analog &lt;strong&gt;speakers&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Both outputs at the same time&lt;/strong&gt; (dual mode)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of this is done using &lt;code&gt;pactl&lt;/code&gt;, a bit of Bash, and the flexibility of the Linux audio stack.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;br&gt;
With less than 60 lines of Bash, I built a custom audio controller that gives me more flexibility than any GUI tool.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;
&lt;p&gt;My setup is simple and very common:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;My &lt;strong&gt;headphones are connected to the monitor&lt;/strong&gt;, which connects to the PC via HDMI&lt;/li&gt;
&lt;li&gt;My &lt;strong&gt;speakers are connected to the analog audio output&lt;/strong&gt; of the motherboard&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My father-in-law needed to switch between headphones and speakers — something already possible via the desktop UI — but he also needed to &lt;strong&gt;listen to audio on both devices at the same time&lt;/strong&gt;, for accessibility reasons.&lt;/p&gt;
&lt;p&gt;That’s when I thought:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;“If Linux gives me full system control, why not build my own audio controller?”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-pactl-and-why-it-matters&quot;&gt;What Is &lt;code&gt;pactl&lt;/code&gt; and Why It Matters&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pactl&lt;/code&gt; (&lt;em&gt;PulseAudio Control&lt;/em&gt;) is a command-line utility used to control audio servers such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PulseAudio&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PipeWire&lt;/strong&gt; (fully compatible and now the default on many distributions)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With &lt;code&gt;pactl&lt;/code&gt;, you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;List audio devices (sinks and sources)&lt;/li&gt;
&lt;li&gt;Change volume levels&lt;/li&gt;
&lt;li&gt;Switch default outputs&lt;/li&gt;
&lt;li&gt;Move audio streams between devices&lt;/li&gt;
&lt;li&gt;Load audio modules like &lt;code&gt;module-combine-sink&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It’s essentially the &lt;strong&gt;Swiss Army knife of Linux audio&lt;/strong&gt; — and the foundation of this entire solution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before continuing, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A modern Linux distribution (Ubuntu, Fedora, Arch, NixOS, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PulseAudio or PipeWire&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A terminal and text editor&lt;/li&gt;
&lt;li&gt;At least &lt;strong&gt;two working audio outputs&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; To check which audio server you’re using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl info | grep &quot;Server Name&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-understand-your-audio-setup&quot;&gt;Step 1: Understand Your Audio Setup&lt;/h3&gt;
&lt;p&gt;Before automating anything, you need to understand which audio devices are available.&lt;/p&gt;
&lt;p&gt;List all sinks (audio outputs):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl list sinks short
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;52 alsa_output.pci-0000_01_00.1.hdmi-stereo     RUNNING
57 alsa_output.pci-0000_09_00.6.analog-stereo   SUSPENDED
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RUNNING&lt;/strong&gt; → currently in use  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SUSPENDED&lt;/strong&gt; → available but idle&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;List audio cards:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl list cards
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here you can verify active profiles such as &lt;code&gt;output:analog-stereo&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-basic-volume-control-with-pactl&quot;&gt;Step 2: Basic Volume Control with &lt;code&gt;pactl&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pactl&lt;/code&gt; provides a very useful alias:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@DEFAULT_SINK@&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Increase volume
pactl set-sink-volume @DEFAULT_SINK@ +5%

# Decrease volume
pactl set-sink-volume @DEFAULT_SINK@ -5%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This allows you to build volume controls that work regardless of the active device.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-switching-audio-outputs&quot;&gt;Step 3: Switching Audio Outputs&lt;/h3&gt;
&lt;p&gt;Set a default sink:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl set-default-sink SINK_NAME
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Move active audio streams manually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl list short sink-inputs
pactl move-sink-input STREAM_ID NEW_SINK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These commands are core building blocks for the final script.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-playing-audio-on-two-devices-at-the-same-time-dual-mode-&quot;&gt;Step 4: Playing Audio on Two Devices at the Same Time (Dual Mode)&lt;/h3&gt;
&lt;p&gt;This is where things get interesting.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl load-module module-combine-sink   sink_name=combined_output   slaves=alsa_output.hdmi,alsa_output.analog   sink_properties=device.description=&quot;Dual_HDMI+Analog&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a &lt;strong&gt;virtual audio sink&lt;/strong&gt; that mirrors sound to multiple outputs simultaneously.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; PipeWire maintains full compatibility with this module.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-the-final-script-audioctl&quot;&gt;Step 5: The Final Script — &lt;code&gt;audioctl&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The final script wraps all of these commands into simple actions like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;audioctl hdmi&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;audioctl analog&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;audioctl dual&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;audioctl toggle&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt; &lt;strong&gt;Full source code:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://gist.github.com/igorgbr/8031267dee77acf6d7d298ec72a37f94&quot;&gt;https://gist.github.com/igorgbr/8031267dee77acf6d7d298ec72a37f94&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installing-the-script&quot;&gt;Installing the Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod +x ~/projects/audioctl
mkdir -p ~/bin
ln -sf ~/projects/audioctl ~/bin/audioctl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ensure &lt;code&gt;~/bin&lt;/code&gt; is in your PATH:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export PATH=&quot;$HOME/bin:$PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reload your shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;source ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;extra-tips&quot;&gt;Extra Tips&lt;/h3&gt;
&lt;p&gt;Test with continuous audio:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;speaker-test -t wav -c 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keyboard shortcuts (GNOME, KDE, i3, etc.):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Super + F11&lt;/code&gt; → &lt;code&gt;audioctl toggle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Super + F12&lt;/code&gt; → &lt;code&gt;audioctl dual&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;debug-commands-amp-references&quot;&gt;Debug Commands &amp;amp; References&lt;/h3&gt;
&lt;p&gt;Useful debug commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pactl list sinks short
pactl get-default-sink
pactl list short sink-inputs
pactl list cards
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Official documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PulseAudio CLI:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI/&quot;&gt;https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/CLI/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PipeWire:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://pipewire.org/&quot;&gt;https://pipewire.org/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;module-combine-sink:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/#index7h2&quot;&gt;https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/#index7h2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;With &lt;strong&gt;less than 60 lines of Bash&lt;/strong&gt;, I solved a real-world problem and gained a level of audio control that no graphical application offered me.&lt;/p&gt;
&lt;p&gt;More importantly, this project helped me deeply understand &lt;strong&gt;how Linux audio works under the hood&lt;/strong&gt; and how small automations can dramatically improve everyday workflows.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;✨ &lt;strong&gt;The control is yours — and it lives in the terminal.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Process Mgmt</category>
<guid isPermaLink="true">https://coderlegion.com/8660/custom-audio-control-linux-switch-between-headphones-speakers-and-dual-output-with-single</guid>
<pubDate>Sat, 20 Dec 2025 21:30:50 +0000</pubDate>
</item>
<item>
<title>FFmpeg Beginner Guide</title>
<link>https://coderlegion.com/6717/ffmpeg-beginner-guide</link>
<description>&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;FFmpeg is one of the most powerful open-source tools for handling &lt;strong&gt;multimedia files&lt;/strong&gt;. Whether you need to &lt;strong&gt;convert, compress, record, or stream audio and video&lt;/strong&gt;, FFmpeg is the go-to command-line utility trusted by developers, content creators, and even large companies.&lt;/p&gt;
&lt;p&gt;It supports &lt;strong&gt;almost every audio and video format&lt;/strong&gt;, making it an essential tool in video editing, streaming, and automation pipelines.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;brief-history&quot;&gt;Brief History&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Released:&lt;/strong&gt; December 20, 2000&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Creator:&lt;/strong&gt; Fabrice Bellard&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Maintained by:&lt;/strong&gt; FFmpeg team &amp;amp; many open-source contributors&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The name &lt;strong&gt;FFmpeg&lt;/strong&gt; comes from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;FF&lt;/strong&gt; → &quot;Fast Forward&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MPEG&lt;/strong&gt; → a popular video compression standard (Moving Picture Experts Group)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since its release, FFmpeg has become a backbone for many popular software and platforms including &lt;strong&gt;VLC Media Player, YouTube, and OBS Studio&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-use-ffmpeg-&quot;&gt;Why Use FFmpeg?&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Convert videos between formats (MP4, MKV, AVI, MOV, etc.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Extract or convert audio (MP3, WAV, AAC, etc.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compress media without losing much quality&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Capture screenshots from videos&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Stream audio/video over networks&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automate video/audio processing in scripts&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installation&quot;&gt;Installation&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;On Linux (Fedora/Ubuntu):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo dnf install ffmpeg   # Fedora
sudo apt install ffmpeg   # Ubuntu/Debian
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;On macOS (with Homebrew):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install ffmpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;On Windows:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Download builds from &lt;a rel=&quot;nofollow&quot; href=&quot;http://ffmpeg.org&quot;&gt;ffmpeg.org&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add ffmpeg/bin to your PATH&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;basic-commands-to-get-started&quot;&gt;Basic Commands to get started&lt;/h3&gt;
&lt;h4 id=&quot;1-video-based-commands&quot;&gt;1. Video-based Commands&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;convert-a-video-to-another-format&quot;&gt;Convert a video to another format&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i input.mp4 output.mkv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It converts an MP4 file into MKV format.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;extract-audio-from-a-video&quot;&gt;Extract audio from a video&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i video.mp4 -q:a 0 -map a audio.mp3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Takes the audio from a video and saves it as MP3.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;compress-a-video&quot;&gt;Compress a video&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i input.mp4 -vcodec libx265 -crf 28 output.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Uses the &lt;strong&gt;H.265 codec&lt;/strong&gt; with CRF (quality factor) for compression.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;capture-a-frame-screenshot-from-a-video&quot;&gt;Capture a frame (screenshot) from a video&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i video.mp4 -ss 00:00:05 -vframes 1 screenshot.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Takes a screenshot at the 5-second mark.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;record-screen-linux-example-&quot;&gt;Record screen (Linux example)&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -video_size 1920x1080 -framerate 30 -f x11grab -i :0.0 output.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Records the desktop screen.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-image-based-commands&quot;&gt;2. Image-based Commands&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;convert-png-to-jpg&quot;&gt;Convert PNG to JPG&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i input.png output.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the same way we can convert to other types too.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;convert-multiple-images-into-a-video&quot;&gt;Convert multiple images into a video&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -framerate 24 -i img%03d.png output.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you have files like &lt;code&gt;img001.png, img002.png...&lt;/code&gt;, this creates a video at 24 fps.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;extract-all-frames-from-a-video-as-images&quot;&gt;Extract all frames from a video as images&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i video.mp4 frame_%04d.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It saves every frame as an image: &lt;code&gt;frame_0001.png, frame_0002.png...&lt;/code&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;resize-an-image&quot;&gt;Resize an image&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ffmpeg -i input.png -vf scale=800:600 resized.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It resizes an image to &lt;strong&gt;800×600&lt;/strong&gt; pixels.&lt;/p&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h5 id=&quot;convert-image-format-batch-all-png-jpg-&quot;&gt;Convert image format batch (all PNG → JPG)&lt;/h5&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for f in *.png; do ffmpeg -i &quot;$f&quot; &quot;${f%.png}.jpg&quot;; done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Converts all &lt;code&gt;.png&lt;/code&gt; files in a folder into &lt;code&gt;.jpg&lt;/code&gt;. (Linux/Mac shell loop)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;FFmpeg may seem intimidating at first because it’s command-line based, but once you learn a few commands, you can &lt;strong&gt;convert, compress, and manipulate media effortlessly&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It’s free, fast, and reliable, so no wonder it powers some of the biggest platforms in the world.&lt;/p&gt;
</description>
<category>Backend Dev</category>
<guid isPermaLink="true">https://coderlegion.com/6717/ffmpeg-beginner-guide</guid>
<pubDate>Wed, 22 Oct 2025 05:12:36 +0000</pubDate>
</item>
<item>
<title>SCR4K: The Flagship of Modern Screen Recordings</title>
<link>https://coderlegion.com/6552/scr4k-the-flagship-of-modern-screen-recordings</link>
<description>&lt;p&gt;In a world where precision and performance define professional content creation, Screen Capture Recorder 4K Chrome Extension (&lt;a rel=&quot;nofollow&quot; href=&quot;https://ko-fi.com/s/37deb99c0b&quot;&gt;SCR4K&lt;/a&gt;) stands as the benchmark for clarity and reliability. Built with a lightweight structure, SCR4K, part of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://eyedolise.github.io/chromescreenrecord/&quot;&gt;Professional Screen Capture Suite&lt;/a&gt;, delivers uncompromising 4K quality without the heavy processing footprint typical of recording tools. This efficiency makes it ideal not only for creators but also for developers seeking a recording engine that integrates seamlessly with modern web environments.&lt;/p&gt;
&lt;p&gt;What makes SCR4K exceptional is its universal adaptability. It functions smoothly across Windows, macOS, and Linux, ensuring consistent performance regardless of platform. Its design philosophy prioritizes minimal system impact while maintaining maximum output fidelity—an achievement that allows even lower-powered systems to capture and encode 4K visuals without lag. For professionals who demand smooth, real-time recording and crisp screenshots, SCR4K provides a dependable and scalable solution.&lt;/p&gt;
&lt;p&gt;For developers, SCR4K is more than an extension—it’s an integration-ready component. With its straightforward API compatibility and browser-based structure, it can be embedded into any recording or streaming web application within minutes. This flexibility opens doors for SaaS platforms, educators, and enterprise developers to integrate high-definition capture tools into their systems effortlessly. SCR4K isn’t just another recorder—it’s the lightweight core of the next generation of visual content tools, ready to elevate any project today!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/6552/scr4k-the-flagship-of-modern-screen-recordings</guid>
<pubDate>Fri, 17 Oct 2025 07:30:35 +0000</pubDate>
</item>
</channel>
</rss>