Add iOS client for WhisperLive (Audio-Transcription-iOS)
This commit is contained in:
@@ -0,0 +1,229 @@
|
|||||||
|
// AudioStream.swift
|
||||||
|
// Lecture2Quiz
|
||||||
|
//
|
||||||
|
// Created by ParkMazorika on 4/27/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Streams audio input to a WebSocket after converting and normalizing.
|
||||||
|
class AudioStreamer {
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private let inputNode: AVAudioInputNode
|
||||||
|
private var inputFormat: AVAudioFormat?
|
||||||
|
private var isPaused: Bool = false
|
||||||
|
private var audioWebSocket: AudioWebSocket?
|
||||||
|
private var partialBuffer = Data()
|
||||||
|
private var isStreaming: Bool = false
|
||||||
|
|
||||||
|
private var bufferSize: AVAudioFrameCount = 1600 // ~100ms of audio
|
||||||
|
private var sampleRate: Double = 16000
|
||||||
|
private var channels: UInt32 = 1
|
||||||
|
|
||||||
|
private var converter: AVAudioConverter?
|
||||||
|
|
||||||
|
init(webSocket: AudioWebSocket) {
|
||||||
|
self.inputNode = engine.inputNode
|
||||||
|
self.audioWebSocket = webSocket
|
||||||
|
|
||||||
|
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||||
|
print("Input format: \(inputFormat)")
|
||||||
|
|
||||||
|
let outputFormat = AVAudioFormat(
|
||||||
|
commonFormat: .pcmFormatInt16,
|
||||||
|
sampleRate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
interleaved: true
|
||||||
|
)!
|
||||||
|
|
||||||
|
self.converter = AVAudioConverter(from: inputFormat, to: outputFormat)
|
||||||
|
self.inputFormat = outputFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures the audio session for recording.
|
||||||
|
func configureAudioSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker])
|
||||||
|
try session.setPreferredSampleRate(48000)
|
||||||
|
try session.setPreferredInputNumberOfChannels(1)
|
||||||
|
try session.setMode(.videoChat)
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
sampleRate = session.sampleRate
|
||||||
|
channels = UInt32(session.inputNumberOfChannels)
|
||||||
|
print("Sample rate: \(sampleRate)")
|
||||||
|
print("Input channels: \(channels)")
|
||||||
|
} catch {
|
||||||
|
print("Failed to configure audio session: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts capturing and streaming audio data.
|
||||||
|
func startStreaming() {
|
||||||
|
guard !isStreaming else {
|
||||||
|
print("Already streaming.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configureAudioSession()
|
||||||
|
|
||||||
|
let format = AVAudioFormat(
|
||||||
|
commonFormat: .pcmFormatFloat32,
|
||||||
|
sampleRate: 48000,
|
||||||
|
channels: channels,
|
||||||
|
interleaved: true
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let hardwareFormat = format else {
|
||||||
|
print("Failed to create audio format.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inputFormat = hardwareFormat
|
||||||
|
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
|
||||||
|
self?.processAudioBuffer(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
isStreaming = true
|
||||||
|
print("AVAudioEngine started.")
|
||||||
|
} catch {
|
||||||
|
print("Failed to start AVAudioEngine: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts and sends the audio buffer to the server via WebSocket.
|
||||||
|
func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
guard let converter = self.converter else {
|
||||||
|
print("Audio converter is nil.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let floatChannelData = buffer.floatChannelData {
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let channelData = Array(UnsafeBufferPointer(start: floatChannelData.pointee, count: frameLength))
|
||||||
|
let rms = sqrt(channelData.map { $0 * $0 }.reduce(0, +) / Float(frameLength))
|
||||||
|
print("Audio RMS: \(rms)")
|
||||||
|
if rms < 0.001 {
|
||||||
|
print("Warning: Input volume is too low.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputFormat = AVAudioFormat(
|
||||||
|
commonFormat: .pcmFormatInt16,
|
||||||
|
sampleRate: 16000,
|
||||||
|
channels: 1,
|
||||||
|
interleaved: true
|
||||||
|
)!
|
||||||
|
|
||||||
|
guard let newBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: 1600) else {
|
||||||
|
print("Failed to allocate PCM buffer.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
var error: NSError?
|
||||||
|
converter.convert(to: newBuffer, error: &error, withInputFrom: inputBlock)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
print("Audio conversion failed: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Converted buffer frameLength: \(newBuffer.frameLength), sampleRate: \(newBuffer.format.sampleRate)")
|
||||||
|
|
||||||
|
if let audioData = convertToFloat32BytesLikePython(newBuffer) {
|
||||||
|
var completeData = partialBuffer + audioData
|
||||||
|
let chunkSize = 4096
|
||||||
|
|
||||||
|
while completeData.count >= chunkSize {
|
||||||
|
let chunk = completeData.prefix(chunkSize)
|
||||||
|
audioWebSocket?.sendDataToServer(chunk)
|
||||||
|
print("Sent 4096 bytes of audio.")
|
||||||
|
completeData.removeFirst(chunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
partialBuffer = completeData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the audio buffer to Float32 Data with RMS normalization and soft clipping.
|
||||||
|
func convertToFloat32BytesLikePython(_ buffer: AVAudioPCMBuffer) -> Data? {
|
||||||
|
guard let int16ChannelData = buffer.int16ChannelData else {
|
||||||
|
print("int16ChannelData is nil.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let channelPointer = int16ChannelData.pointee
|
||||||
|
|
||||||
|
var floatArray = [Float32](repeating: 0, count: frameLength)
|
||||||
|
for i in 0..<frameLength {
|
||||||
|
let int16Value = channelPointer[i]
|
||||||
|
floatArray[i] = Float32(Int16(littleEndian: int16Value)) / 32768.0
|
||||||
|
}
|
||||||
|
|
||||||
|
let rms = sqrt(floatArray.map { $0 * $0 }.reduce(0, +) / Float(frameLength))
|
||||||
|
let targetRMS: Float32 = 0.25
|
||||||
|
let gain = targetRMS / max(rms, 0.00001)
|
||||||
|
|
||||||
|
print("Original RMS: \(rms), applied gain: \(gain)")
|
||||||
|
|
||||||
|
for i in 0..<frameLength {
|
||||||
|
let scaled = floatArray[i] * gain
|
||||||
|
let clipped = tanh(scaled * 3.0)
|
||||||
|
floatArray[i] = clipped
|
||||||
|
}
|
||||||
|
|
||||||
|
let floatData = Data(bytes: floatArray, count: frameLength * MemoryLayout<Float32>.size)
|
||||||
|
|
||||||
|
if let minVal = floatArray.min(), let maxVal = floatArray.max() {
|
||||||
|
print("Float32 value range after normalization: \(minVal)...\(maxVal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Converted to Float32 data: \(floatData.count) bytes")
|
||||||
|
return floatData
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses audio streaming by removing the input tap.
|
||||||
|
func pauseStreaming() {
|
||||||
|
guard !isPaused else { return }
|
||||||
|
inputNode.removeTap(onBus: 0)
|
||||||
|
isPaused = true
|
||||||
|
print("Audio streaming paused.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes audio streaming by reinstalling the input tap.
|
||||||
|
func resumeStreaming() {
|
||||||
|
guard isPaused else { return }
|
||||||
|
guard let inputFormat = inputFormat else {
|
||||||
|
print("inputFormat is nil.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
|
||||||
|
self?.processAudioBuffer(buffer)
|
||||||
|
}
|
||||||
|
isPaused = false
|
||||||
|
print("Audio streaming resumed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the AVAudioEngine and resets streaming state.
|
||||||
|
func stopStreaming() {
|
||||||
|
guard isStreaming else {
|
||||||
|
print("Already stopped.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
isStreaming = false
|
||||||
|
print("AVAudioEngine stopped.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//
|
||||||
|
// RecordingViewModel.swift
|
||||||
|
// Lecture2Quiz
|
||||||
|
//
|
||||||
|
// Created by ParkMazorika on 4/27/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// WebSocket client that connects to a transcription server and handles streaming, JSON messages, and retries.
|
||||||
|
class AudioWebSocket: NSObject, URLSessionWebSocketDelegate {
|
||||||
|
private var webSocketTask: URLSessionWebSocketTask?
|
||||||
|
private var urlSession: URLSession!
|
||||||
|
private let host: String
|
||||||
|
private let port: Int
|
||||||
|
private var retryCount = 0
|
||||||
|
private let maxRetries = 3
|
||||||
|
private var uid: String
|
||||||
|
private let modelSize: String
|
||||||
|
private var pingTimer: Timer?
|
||||||
|
private var processedTexts = Set<String>()
|
||||||
|
|
||||||
|
var onServerReady: (() -> Void)?
|
||||||
|
var onTranscriptionReceived: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(host: String, port: Int, modelSize: String = "medium") {
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.uid = UUID().uuidString
|
||||||
|
self.modelSize = modelSize
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
self.urlSession = URLSession(
|
||||||
|
configuration: .default,
|
||||||
|
delegate: self,
|
||||||
|
delegateQueue: .main
|
||||||
|
)
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establishes a WebSocket connection with the configured server.
|
||||||
|
private func connect() {
|
||||||
|
guard retryCount <= maxRetries else {
|
||||||
|
print("Maximum reconnect attempts exceeded.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketURL = port == 443 || port == 80
|
||||||
|
? "wss://\(host)"
|
||||||
|
: "wss://\(host):\(port)"
|
||||||
|
|
||||||
|
guard let url = URL(string: socketURL) else {
|
||||||
|
print("Invalid URL: \(socketURL)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocketTask = urlSession.webSocketTask(with: url)
|
||||||
|
webSocketTask?.resume()
|
||||||
|
print("Attempting WebSocket connection: \(socketURL)")
|
||||||
|
|
||||||
|
listen()
|
||||||
|
sendInitialJSON()
|
||||||
|
startPing()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the initial JSON payload to identify and configure the session.
|
||||||
|
private func sendInitialJSON() {
|
||||||
|
let jsonPayload: [String: Any] = [
|
||||||
|
"uid": uid,
|
||||||
|
"language": "en",
|
||||||
|
"task": "transcribe",
|
||||||
|
"model": modelSize,
|
||||||
|
"use_vad": true,
|
||||||
|
"max_clients": 4,
|
||||||
|
"max_connection_time": 600
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: jsonPayload, options: [])
|
||||||
|
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
||||||
|
print("Sending config JSON: \(jsonString)")
|
||||||
|
|
||||||
|
webSocketTask?.send(.string(jsonString)) { [weak self] error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to send config JSON: \(error.localizedDescription)")
|
||||||
|
self?.reconnect()
|
||||||
|
} else {
|
||||||
|
print("Config JSON sent successfully.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("JSON serialization error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends audio data to the server.
|
||||||
|
func sendDataToServer(_ data: Data) {
|
||||||
|
guard isConnected else {
|
||||||
|
print("Not connected - skipping data send.")
|
||||||
|
reconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocketTask?.send(.data(data)) { [weak self] error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to send audio data: \(error.localizedDescription)")
|
||||||
|
self?.reconnect()
|
||||||
|
} else {
|
||||||
|
print("Sent audio data: \(data.count) bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the WebSocket is currently connected.
|
||||||
|
internal var isConnected: Bool {
|
||||||
|
webSocketTask?.state == .running
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts reconnection with exponential backoff.
|
||||||
|
private func reconnect() {
|
||||||
|
retryCount += 1
|
||||||
|
stopPing()
|
||||||
|
let delay = min(5.0, pow(2.0, Double(retryCount)))
|
||||||
|
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
print("Reconnecting... (\(self?.retryCount ?? 0)/\(self?.maxRetries ?? 0))")
|
||||||
|
self?.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts listening for incoming messages from the server.
|
||||||
|
private func listen() {
|
||||||
|
webSocketTask?.receive { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
self?.handleMessage(message)
|
||||||
|
self?.listen()
|
||||||
|
case .failure(let error):
|
||||||
|
print("Receive error: \(error.localizedDescription)")
|
||||||
|
self?.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles incoming WebSocket messages (text or binary).
|
||||||
|
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
||||||
|
switch message {
|
||||||
|
case .data(let data):
|
||||||
|
print("Received binary data: \(data.count) bytes")
|
||||||
|
|
||||||
|
case .string(let text):
|
||||||
|
print("Received text message: \(text)")
|
||||||
|
|
||||||
|
guard let data = text.data(using: .utf8) else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
if let status = json["status"] as? String {
|
||||||
|
handleStatusMessage(status: status, message: json["message"] as? String)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = json["message"] as? String, message == "SERVER_READY" {
|
||||||
|
print("Server is ready.")
|
||||||
|
onServerReady?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let segments = json["segments"] as? [[String: Any]] {
|
||||||
|
let wrapped = ["segments": segments]
|
||||||
|
let segmentData = try JSONSerialization.data(withJSONObject: wrapped, options: [])
|
||||||
|
let segmentString = String(data: segmentData, encoding: .utf8)!
|
||||||
|
onTranscriptionReceived?(segmentString)
|
||||||
|
print("Transcription segments forwarded.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("JSON parsing error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
print("Unknown message type received.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles status message JSON from the server.
|
||||||
|
private func handleStatusMessage(status: String, message: String?) {
|
||||||
|
switch status {
|
||||||
|
case "WAIT":
|
||||||
|
print("Waiting: \(message ?? "")")
|
||||||
|
case "ERROR":
|
||||||
|
print("Error: \(message ?? "")")
|
||||||
|
case "WARNING":
|
||||||
|
print("Warning: \(message ?? "")")
|
||||||
|
default:
|
||||||
|
print("\(status): \(message ?? "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the "END_OF_AUDIO" signal to the server.
|
||||||
|
func sendEndOfAudio() {
|
||||||
|
guard isConnected else {
|
||||||
|
print("Not connected - skipping END_OF_AUDIO.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocketTask?.send(.string("END_OF_AUDIO")) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to send END_OF_AUDIO: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
print("END_OF_AUDIO sent.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully closes the WebSocket connection.
|
||||||
|
func closeConnection() {
|
||||||
|
stopPing()
|
||||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||||
|
retryCount = maxRetries
|
||||||
|
print("WebSocket closed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts periodic ping to keep the WebSocket alive.
|
||||||
|
private func startPing() {
|
||||||
|
stopPing()
|
||||||
|
pingTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
|
||||||
|
self?.webSocketTask?.sendPing { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Ping failed: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
print("Ping sent successfully.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunLoop.main.add(pingTimer!, forMode: .common)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the periodic ping timer.
|
||||||
|
private func stopPing() {
|
||||||
|
pingTimer?.invalidate()
|
||||||
|
pingTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the WebSocket is closed by the server.
|
||||||
|
func urlSession(_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?) {
|
||||||
|
let reasonString = String(data: reason ?? Data(), encoding: .utf8) ?? "No reason"
|
||||||
|
print("WebSocket closed - code: \(closeCode.rawValue), reason: \(reasonString)")
|
||||||
|
stopPing()
|
||||||
|
reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// WhisperLive_iOS_Client
|
||||||
|
//
|
||||||
|
// Created by ParkMazorika on 6/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A standalone view for recording and real-time transcription display.
|
||||||
|
struct RecordingView: View {
|
||||||
|
var onDismiss: () -> Void
|
||||||
|
@StateObject private var recordingViewModel = AudioViewModel()
|
||||||
|
@State private var showSubmitView = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Stop button (only visible when recording)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
if recordingViewModel.isRecording {
|
||||||
|
Button("Stop Recording") {
|
||||||
|
recordingViewModel.stopRecording()
|
||||||
|
recordingViewModel.finalizeTranscription()
|
||||||
|
showSubmitView = true
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.padding()
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcription display
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(recordingViewModel.transcriptionList.indices, id: \.self) { index in
|
||||||
|
Text(recordingViewModel.transcriptionList[index])
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().padding(.top, 8)
|
||||||
|
|
||||||
|
// Timer and Record/Pause/Resume button
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(recordingViewModel.timeLabel)
|
||||||
|
.font(.system(size: 40))
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if recordingViewModel.isRecording {
|
||||||
|
recordingViewModel.isPaused
|
||||||
|
? recordingViewModel.resumeRecording()
|
||||||
|
: recordingViewModel.pauseRecording()
|
||||||
|
} else {
|
||||||
|
recordingViewModel.startRecording()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: recordingViewModel.isRecording
|
||||||
|
? (recordingViewModel.isPaused ? "play.circle.fill" : "pause.circle.fill")
|
||||||
|
: "mic.circle.fill")
|
||||||
|
.font(.system(size: 50))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.overlay(
|
||||||
|
Group {
|
||||||
|
if recordingViewModel.isLoading {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.4).ignoresSafeArea()
|
||||||
|
ProgressView("Processing...")
|
||||||
|
.padding()
|
||||||
|
.background(Color.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.sheet(isPresented: $showSubmitView) {
|
||||||
|
//anotherView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Recording View") {
|
||||||
|
RecordingView {
|
||||||
|
// Dummy dismiss handler
|
||||||
|
print("RecordingView dismissed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Audio-Transcription-iOS
|
||||||
|
|
||||||
|
This is an iOS client for [WhisperLive](https://github.com/collabora/WhisperLive), a real-time speech-to-text server based on OpenAI Whisper.
|
||||||
|
The app streams microphone audio to a WhisperLive server via WebSocket and displays live transcription results in real time.
|
||||||
|
|
||||||
|
> ⚠️ This client is designed to work specifically with the [WhisperLive Python WebSocket server](https://github.com/whisperlive/whisperlive).
|
||||||
|
> Make sure the server is running and reachable from your iOS device.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time microphone capture with AVAudioEngine
|
||||||
|
- Streaming to WhisperLive backend using WebSocket
|
||||||
|
- Displays transcription as segments arrive
|
||||||
|
- Start / Pause / Resume / Stop recording with SwiftUI interface
|
||||||
|
- Final transcription view on stop
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- iOS 15.0+
|
||||||
|
- Swift 5.8+
|
||||||
|
- AVFoundation (for microphone)
|
||||||
|
- Working WhisperLive WebSocket server
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Clone the repository (your fork):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/whisperlive.git
|
||||||
|
cd whisperlive/Audio-Transcription-iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open the `.xcodeproj` or `.xcodeworkspace` in Xcode
|
||||||
|
|
||||||
|
3. Add the following to your `Info.plist`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>This app requires microphone access for transcription.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the app on a physical device (recommended)
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
Audio-Transcription-iOS/
|
||||||
|
├── AudioViewModel.swift
|
||||||
|
├── AudioStreamer.swift
|
||||||
|
├── AudioWebSocket.swift
|
||||||
|
├── RecordingView.swift
|
||||||
|
├── WhisperLive_iOS_ClientApp.swift
|
||||||
|
├── Info.plist
|
||||||
|
├── README.md
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
This iOS client is provided as an open-source example to complement WhisperLive's real-time transcription ecosystem.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// RecordingViewModel.swift
|
||||||
|
// Lecture2Quiz
|
||||||
|
//
|
||||||
|
// Created by ParkMazorika on 4/27/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Represents a segment of transcribed audio with start/end timestamps and completion flag.
|
||||||
|
struct TranscriptionSegment: Identifiable, Equatable {
|
||||||
|
var id = UUID()
|
||||||
|
var start: Double
|
||||||
|
var end: Double
|
||||||
|
var text: String
|
||||||
|
var completed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ViewModel responsible for managing audio recording and transcription logic.
|
||||||
|
class AudioViewModel: ObservableObject {
|
||||||
|
@Published var isRecording = false // Indicates if recording is active
|
||||||
|
@Published var isPaused = false // Indicates if recording is currently paused
|
||||||
|
@Published var timeLabel = "00:00" // Timer label formatted as mm:ss
|
||||||
|
@Published var transcriptionList: [String] = [] // Live transcription output
|
||||||
|
@Published var isLoading = false // True while waiting for server response
|
||||||
|
@Published var finalScript: String = "" // Final script from completed segments
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private var elapsedTime: Int = 0
|
||||||
|
|
||||||
|
private var audioStreamer: AudioStreamer? // Handles audio capture and streaming
|
||||||
|
private var audioWebSocket: AudioWebSocket? // Manages WebSocket communication
|
||||||
|
|
||||||
|
private var segments: [TranscriptionSegment] = [] // Stores all transcription segments
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// Starts audio recording and initializes WebSocket + AVAudioEngine.
|
||||||
|
func startRecording() {
|
||||||
|
let audioAPIUrl = "your server url"
|
||||||
|
audioWebSocket = AudioWebSocket(host: audioAPIUrl, port: 443)
|
||||||
|
audioStreamer = AudioStreamer(webSocket: audioWebSocket!)
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
// Handle server transcription message
|
||||||
|
audioWebSocket?.onTranscriptionReceived = { [weak self] text in
|
||||||
|
self?.handleRawTranscriptionJSON(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When server sends SERVER_READY
|
||||||
|
audioWebSocket?.onServerReady = { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isLoading = false
|
||||||
|
self.isRecording = true
|
||||||
|
self.isPaused = false
|
||||||
|
self.timeLabel = "00:00"
|
||||||
|
self.elapsedTime = 0
|
||||||
|
self.startTimer()
|
||||||
|
self.audioStreamer?.startStreaming()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses the recording and stops the timer.
|
||||||
|
func pauseRecording() {
|
||||||
|
isPaused = true
|
||||||
|
audioStreamer?.pauseStreaming()
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes recording and restarts the timer.
|
||||||
|
func resumeRecording() {
|
||||||
|
isPaused = false
|
||||||
|
audioStreamer?.resumeStreaming()
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops recording and finalizes connection to server.
|
||||||
|
func stopRecording() {
|
||||||
|
isRecording = false
|
||||||
|
isPaused = false
|
||||||
|
timer?.invalidate()
|
||||||
|
|
||||||
|
audioStreamer?.stopStreaming()
|
||||||
|
audioWebSocket?.sendEndOfAudio()
|
||||||
|
audioWebSocket?.onTranscriptionReceived = nil
|
||||||
|
audioWebSocket?.closeConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the recording timer (1-second interval).
|
||||||
|
private func startTimer() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||||
|
self.elapsedTime += 1
|
||||||
|
let minutes = self.elapsedTime / 60
|
||||||
|
let seconds = self.elapsedTime % 60
|
||||||
|
self.timeLabel = String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalizes the transcription by joining all completed segments into one string.
|
||||||
|
func finalizeTranscription() {
|
||||||
|
isLoading = false
|
||||||
|
let completedText = segments
|
||||||
|
.filter { $0.completed }
|
||||||
|
.map { $0.text.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.joined(separator: " ")
|
||||||
|
finalScript = completedText
|
||||||
|
print("Final transcript:\n\(finalScript)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles incoming JSON from the server and updates UI state.
|
||||||
|
/// Supports both full JSON and raw string cases.
|
||||||
|
func handleRawTranscriptionJSON(_ jsonString: String) {
|
||||||
|
let trimmed = jsonString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let data = trimmed.data(using: .utf8) else { return }
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("{") {
|
||||||
|
// Parse JSON containing segment list
|
||||||
|
do {
|
||||||
|
if let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let segmentDicts = dict["segments"] as? [[String: Any]] {
|
||||||
|
|
||||||
|
for item in segmentDicts {
|
||||||
|
guard let startStr = item["start"] as? String,
|
||||||
|
let endStr = item["end"] as? String,
|
||||||
|
let text = item["text"] as? String,
|
||||||
|
let completed = item["completed"] as? Bool,
|
||||||
|
let start = Double(startStr),
|
||||||
|
let end = Double(endStr) else { continue }
|
||||||
|
|
||||||
|
let newSegment = TranscriptionSegment(start: start, end: end, text: text, completed: completed)
|
||||||
|
|
||||||
|
// Overwrite if already exists, else append
|
||||||
|
if let index = self.segments.firstIndex(where: { $0.start == start }) {
|
||||||
|
self.segments[index] = newSegment
|
||||||
|
} else {
|
||||||
|
self.segments.append(newSegment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let completedTexts = self.segments
|
||||||
|
.filter { $0.completed }
|
||||||
|
.sorted(by: { $0.start < $1.start })
|
||||||
|
.map { $0.text.trimmingCharacters(in: .whitespaces) }
|
||||||
|
|
||||||
|
let pendingText = self.segments
|
||||||
|
.filter { !$0.completed }
|
||||||
|
.sorted(by: { $0.start < $1.start })
|
||||||
|
.map { $0.text.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.last ?? ""
|
||||||
|
|
||||||
|
self.transcriptionList = completedTexts + (pendingText.isEmpty ? [] : [pendingText])
|
||||||
|
self.finalScript = self.transcriptionList.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("JSON parsing error: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle raw text line
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.transcriptionList.last != trimmed {
|
||||||
|
self.transcriptionList.append(trimmed)
|
||||||
|
self.finalScript = self.transcriptionList.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>This app requires microphone access for voice transcription.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// WhisperLive_iOS_ClientApp.swift
|
||||||
|
// WhisperLive_iOS_Client
|
||||||
|
//
|
||||||
|
// Created by 바견규 on 6/17/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WhisperLive_iOS_ClientApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RecordingView {
|
||||||
|
// Handle dismiss action here, or leave it empty for now
|
||||||
|
print("RecordingView dismissed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user