admaDIC App Development & IT Solutions

Create Morse Code and Play it - Morse Code Views

by Annett Schwarze | 2025-08-22

This article is part of a series in which a morse code player is implemented. As a third step, the views are built, which tie everything together.

There are two views. A player view for controlling the MorseCodePlayer. The second view is the main view, which holds all model instances and contains the player view.

        
struct AudioPlayerView: View {
    let playString: String
    @State private var isPlaying = false
    @State private var sliderProgress: Double = 0
    @State private var progress: Double = 0
    @State private var total: Double = 0

    var body: some View {
        HStack(spacing: 12) {
            Button(action: {
                didTapButton()
            }, label: {
                ZStack {
                    Circle()
                        .fill(Color.white)
                        .frame(width: 28, height: 28)
                    Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                        .foregroundColor(.gray)
                }
            })
            .buttonStyle(PlainButtonStyle())

            Text(timeString(from: progress))
                .font(.system(size: 14).monospacedDigit())
                .foregroundColor(.white)

            Slider(value: $sliderProgress, in: 0...1)
                .accentColor(.white)
                .tint(.white)
                .disabled(true)

            Text(timeString(from: total))
                .font(.system(size: 14).monospacedDigit())
                .foregroundColor(.white)
        }
        .padding(.vertical, 8)
        .padding(.horizontal, 10)
        .background(Color.gray.opacity(0.6))
        .cornerRadius(12)
    }

    private func timeString(from progress: Double) -> String {
        let duration = Duration.seconds(progress)
        let string = duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 1)))
        return string
    }

    private func didTapButton() {
        if isPlaying {
            isPlaying = false
            MorseCodePlayer.shared.stop()
        } else {
            isPlaying = true
            MorseCodePlayer.shared.play(morse: playString, onProgress: { prog, total in
                self.progress = prog
                self.total = total
                self.sliderProgress = total > 0 ? prog / total : 0.0
            }, onFinish: {
                self.isPlaying = false
                self.sliderProgress = 1
            })
        }
    }
}

struct SampleMorseCodeView: View {

    @State private var inputText: String = ""
    @State private var morseCodeText: AttributedString = AttributedString()
    @State private var morseCodePlayText: String = ""

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample Morse Code View")
                .font(.largeTitle)
                .padding(.vertical, 20)
            TextField("Text to encode", text: $inputText)
                .padding(8)
                .background {
                    RoundedRectangle(cornerRadius: 12)
                        .fill(Color.white)
                }

            Button(action: { createMorseCode() }, label: {
                Text("Create Morse Code")
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            })

            VStack(alignment: .leading) {
                Text(morseCodeText.characters.isEmpty ? "Waiting for input..." : morseCodeText)
                    .foregroundStyle(morseCodeText.characters.isEmpty ? .secondary :  .primary)
                    .font(morseCodeText.characters.isEmpty ? .body : .title)
                    .fontWeight(morseCodeText.characters.isEmpty ? .none : .bold)

                AudioPlayerView(playString: morseCodePlayText)
                    .padding(.top, 10)

            }.padding(.top, 20)

            Spacer()
        }
        .padding()
        .background {
            Color(white: 0.9)
                .ignoresSafeArea()
        }
    }

    func createMorseCode() {
        let msC = MorseCodeCreator()
        (morseCodePlayText, morseCodeText) = msC.textToMorse(inputText)
    }
}
    

For an overview, below is the full code with all parts included.

        
import SwiftUI
import AVFoundation

struct SampleMorseCodeView: View {

    @State private var inputText: String = ""
    @State private var morseCodeText: AttributedString = AttributedString()
    @State private var morseCodePlayText: String = ""

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample Morse Code View")
                .font(.largeTitle)
                .padding(.vertical, 20)
            TextField("Text to encode", text: $inputText)
                .padding(8)
                .background {
                    RoundedRectangle(cornerRadius: 12)
                        .fill(Color.white)
                }

            Button(action: { createMorseCode() }, label: {
                Text("Create Morse Code")
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            })

            VStack(alignment: .leading) {
                Text(morseCodeText.characters.isEmpty ? "Waiting for input..." : morseCodeText)
                    .foregroundStyle(morseCodeText.characters.isEmpty ? .secondary :  .primary)
                    .font(morseCodeText.characters.isEmpty ? .body : .title)
                    .fontWeight(morseCodeText.characters.isEmpty ? .none : .bold)

                AudioPlayerView(playString: morseCodePlayText)
                    .padding(.top, 10)

            }.padding(.top, 20)

            Spacer()
        }
        .padding()
        .background {
            Color(white: 0.9)
                .ignoresSafeArea()
        }
    }

    func createMorseCode() {
        let msC = MorseCodeCreator()
        (morseCodePlayText, morseCodeText) = msC.textToMorse(inputText)
    }

    class MorseCodeCreator {
        let morseCodeDict: [String: String] = [
            // Letters
            "a": ".-",    "b": "-...",  "c": "-.-.",
            "d": "-..",   "e": ".",     "f": "..-.",
            "g": "--.",   "h": "....",  "i": "..",
            "j": ".---",  "k": "-.-",   "l": ".-..",
            "m": "--",    "n": "-.",    "o": "---",
            "p": ".--.",  "q": "--.-",  "r": ".-.",
            "s": "...",   "t": "-",     "u": "..-",
            "v": "...-",  "w": ".--",   "x": "-..-",
            "y": "-.--",  "z": "--..",

            // Punctuation
            ".": ".-.-.-",
            ",": "--..--",
            "?": "..--..",
            "'": ".----.",
            "!": "-.-.--",
            "/": "-..-.",
            "(": "-.--.",
            ")": "-.--.-",
            "&": ".-...",
            ":": "---...",
            ";": "-.-.-.",
            "=": "-...-",
            "+": ".-.-.",
            "-": "-....-",
            "_": "..--.-",
            "\"": ".-..-.",
            "$": "...-..-",
            "@": ".--.-."
        ]

        let colors: [Color] = [.red, .green, .blue, .yellow, .orange, .purple, .pink, .mint, .teal, .indigo, .brown, .cyan]

        func textToWords(_ text: String) -> [[String]] {
            var words: [String] = []
            var word: String = ""
            for char in text.lowercased() {
                let schar = String(char)
                if char == " " {
                    if !word.isEmpty { words.append(word) }
                    word = ""
                } else if schar.range(of: "^[a-z0-9]$", options: .regularExpression) == nil {
                    if !word.isEmpty { words.append(word) }
                    words.append(schar)
                    word = ""
                } else {
                    word.append(char)
                }
            }
            if !word.isEmpty { words.append(word) }
            let morseWords = words.map({ w in
                w.map({ c in self.morseCodeDict[String(c)] ?? "" })
            })
            return morseWords
        }

        func textToMorse(_ text: String) -> (String, AttributedString) {
            let words: [[String]] = textToWords(text)
            var displayString: AttributedString = AttributedString()
            var playString: String = ""
            let awords = words.enumerated().map({ word in
                let str = word.element.joined(separator: " ")
                var a = AttributedString(str)
                a.foregroundColor = colors[word.offset % colors.count]
                return a
            })
            awords.forEach({
                displayString.append($0)
                displayString.append(AttributedString("   "))
            })
            let pwords = words.map({ w in
                w.map({ cstr in
                    let cout = cstr.map({ String($0) }).joined(separator: " ")
                    return cout
                })
                .joined(separator: "   ")
            }).joined(separator: "       ")
            playString += pwords
            return (playString, displayString)
        }
    }

    struct AudioPlayerView: View {
        let playString: String
        @State private var isPlaying = false
        @State private var sliderProgress: Double = 0
        @State private var progress: Double = 0
        @State private var total: Double = 0

        var body: some View {
            HStack(spacing: 12) {
                Button(action: {
                    didTapButton()
                }, label: {
                    ZStack {
                        Circle()
                            .fill(Color.white)
                            .frame(width: 28, height: 28)
                        Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                            .foregroundColor(.gray)
                    }
                })
                .buttonStyle(PlainButtonStyle())

                Text(timeString(from: progress))
                    .font(.system(size: 14).monospacedDigit())
                    .foregroundColor(.white)

                Slider(value: $sliderProgress, in: 0...1)
                    .accentColor(.white)
                    .tint(.white)
                    .disabled(true)

                Text(timeString(from: total))
                    .font(.system(size: 14).monospacedDigit())
                    .foregroundColor(.white)
            }
            .padding(.vertical, 8)
            .padding(.horizontal, 10)
            .background(Color.gray.opacity(0.6))
            .cornerRadius(12)
        }

        private func timeString(from progress: Double) -> String {
            let duration = Duration.seconds(progress)
            let string = duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 1)))
            return string
        }

        private func didTapButton() {
            if isPlaying {
                isPlaying = false
                MorseCodePlayer.shared.stop()
            } else {
                isPlaying = true
                MorseCodePlayer.shared.play(morse: playString, onProgress: { prog, total in
                    self.progress = prog
                    self.total = total
                    self.sliderProgress = total > 0 ? prog / total : 0.0
                }, onFinish: {
                    self.isPlaying = false
                    self.sliderProgress = 1
                })
            }
        }
    }

    class MorseCodePlayer: ObservableObject {
        static let shared = MorseCodePlayer()

        private var task: Task<,Void, Never>?

        private var audioEngine: AVAudioEngine?
        private var playerNode: AVAudioPlayerNode?
        private var isStopped = false

        private var engine: AVAudioEngine?
        private var player: AVAudioPlayerNode?
        private var format: AVAudioFormat?
        private var shortBeep: AVAudioPCMBuffer?
        private var longBeep: AVAudioPCMBuffer?

        typealias UpdateBlock = (_ progress: Double, _ total: Double) -> Void
        typealias FinishBlock = () -> Void

        let dash = "-"
        let dot = "."
        let spc = " "
        let unit: Double = 0.12 // seconds
        let spacePause: Int = 1
        let letterSpacePause: Int = 3
        let wordSpacePause: Int = 7
        let longFactor: Int = 3
        let shortFactor: Int = 1
        var shortDuration: Double { unit * Double(shortFactor) }
        var longDuration: Double { unit * Double(longFactor) }

        private func startup() {
            let engine = AVAudioEngine()
            let player = AVAudioPlayerNode()
            engine.attach(player)
            let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
            engine.connect(player, to: engine.mainMixerNode, format: format)

            self.format = format
            self.engine = engine
            self.player = player

            shortBeep = createBeep(duration: unit * Double(shortFactor))
            longBeep = createBeep(duration: unit * Double(longFactor))

            try? engine.start()
            player.play()
        }

        private func shutdown() {
            player?.stop()
            engine?.stop()

            engine = nil
            player = nil
            format = nil

            shortBeep = nil
            longBeep = nil
        }

        private func createBeep(duration: Double) -> AVAudioPCMBuffer? {
            guard let format else { return nil }

            let frames = UInt32(duration * format.sampleRate)
            let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)!
            buffer.frameLength = frames
            let freq = 900.0
            for i in 0 ..< Int(frames) {
                let t = Double(i) / format.sampleRate
                let val = Float32(sin(2 * .pi * freq * t) * 0.25)
                buffer.floatChannelData!.pointee[i] = val
            }
            return buffer
        }

        func play(morse: String, onProgress: @escaping UpdateBlock, onFinish: @escaping FinishBlock) {
            stop()
            task?.cancel()
            shutdown()

            isStopped = false
            startup()

            task = Task<Void, Never> { [weak self] in
                defer {
                    self?.shutdown()
                }
                guard let self else { return }
                do {
                    let chars = Array(morse)
                    let ticks = chars.map({ [shortFactor, longFactor, dot, dash] in
                        switch String($0) {
                        case dot: return shortFactor
                        case dash: return longFactor
                        default: return shortFactor
                        }
                    }).reduce(0, +)
                    var tick = 0
                    for c in chars where !self.isStopped {
                        if Task.isCancelled {
                            break
                        }
                        switch String(c) {
                        case dot:
                            await beep(buffer: shortBeep, duration: shortDuration, onFinish: {})
                            tick += shortFactor
                        case dash:
                            await beep(buffer: longBeep, duration: longDuration, onFinish: {})
                            tick += longFactor
                        default:
                            try await silence(shortDuration)
                            tick += shortFactor
                        }
                        DispatchQueue.main.async { onProgress(Double(tick), Double(ticks)) }
                    }
                } catch {
                    print("error: \(error.localizedDescription)")
                }
                self.shutdown()
                DispatchQueue.main.async {
                    onFinish()
                }
            }
        }

        private func beep(buffer: AVAudioPCMBuffer?, duration: Double, onFinish: @escaping () -> Void) async {
            guard let buffer else { return }
            guard let player else { return }
            await player.scheduleBuffer(buffer, at: nil, options: .interrupts)
        }

        private func silence(_ duration: Double) async throws {
            try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
        }

        func stop() {
            isStopped = true
            task?.cancel()
        }
    }
}

#Preview {
    SampleMorseCodeView()
}
    
SwiftUI Morse Code View

 

www.admadic.de | webmaster@admadic.de | Legal Notice and Trademarks | Privacy
© 2005-2007 - admaDIC | All Rights Reserved
All other trademarks and/or registered trademarks are the property of their respective owners
Last Change: Fri Aug 22 08:44:50 2025 GMT