Create Morse Code and Play it - Morse Code Views
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()
}