Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions openless-ios/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# XcodeGen 生成物(project.yml 是唯一真源)
*.xcodeproj/

# Xcode
DerivedData/
build/
*.xcuserstate
*.xcscmblueprint
xcuserdata/

# SwiftPM
.build/
.swiftpm/

# 设计参考截图(个人,不入库)
refs/

# macOS
.DS_Store
65 changes: 65 additions & 0 deletions openless-ios/App/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenLess</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<!-- 输入法伴随 App:锁定竖屏;UIRequiresFullScreen 用于消除 “须支持全部方向” 校验告警 -->
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<!-- 语音输入:录音权限 + 后台音频(Phase 5 免跳转常驻会话)-->
<key>NSMicrophoneUsageDescription</key>
<string>OpenLess 需要使用麦克风进行语音输入。</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OpenLess 使用语音识别将你的语音转写为文字。</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- 键盘扩展通过 openless:// 跳回主 App 发起录音 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>top.openless.ios</string>
<key>CFBundleURLSchemes</key>
<array>
<string>openless</string>
</array>
</dict>
</array>
</dict>
</plist>
11 changes: 11 additions & 0 deletions openless-ios/App/OpenLess.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?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>
<!-- 主 App 与键盘扩展共享容器:偏好、风格包、识别结果回传 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.top.openless.ios</string>
</array>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.95",
"green" : "0.30",
"red" : "0.40"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions openless-ios/App/Resources/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
164 changes: 164 additions & 0 deletions openless-ios/App/Sources/ASR/VolcengineASR.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import Foundation
import OpenLessShared

/// 火山引擎 SAUC bigmodel 流式 ASR 客户端(移植自桌面 asr/volcengine.rs)。
/// 用 URLSessionWebSocketTask 连接,发首帧 → 流式发 16k/16bit/mono PCM 音频帧 → 末帧收尾,
/// 接收 FullServerResponse,拼 utterances 文本,partial/final 回调。
final class VolcengineASR {
struct Credentials {
let appKey: String
let accessToken: String
let resourceId: String
}

var onPartial: ((String) -> Void)?
var onFinal: ((String) -> Void)?
var onError: ((String) -> Void)?

private let creds: Credentials
private var task: URLSessionWebSocketTask?
private var session: URLSession?
private var nextSeq: Int32 = 1
private var pending = Data()
private var lastPartial = ""
private var finished = false

private let chunkBytes = 6_400 // 200ms @ 16k/16bit/mono
private let endpoint = URL(string: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async")!

init(creds: Credentials) { self.creds = creds }

func start() {
let connectId = UUID().uuidString
var req = URLRequest(url: endpoint)
req.setValue(creds.appKey, forHTTPHeaderField: "X-Api-App-Key")
req.setValue(creds.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
req.setValue(creds.resourceId, forHTTPHeaderField: "X-Api-Resource-Id")
req.setValue(connectId, forHTTPHeaderField: "X-Api-Connect-Id")

let s = URLSession(configuration: .default)
session = s
let t = s.webSocketTask(with: req)
task = t
t.resume()

// 首帧:FullClientRequest + JSON(音频参数 + 请求参数)
let payload: [String: Any] = [
"user": ["uid": connectId],
"audio": ["format": "pcm", "rate": 16_000, "bits": 16, "channel": 1, "codec": "raw"],
"request": [
"model_name": "bigmodel",
"enable_itn": true,
"enable_punc": true,
"show_utterances": true,
],
]
guard let body = try? JSONSerialization.data(withJSONObject: payload) else {
onError?("payload 编码失败"); return
}
send(VolcFrame.build(.fullClientRequest, .positiveSequence, .json, payload: body, sequence: allocSeq()))
receiveLoop()
}

/// 喂入 16k/16bit/mono PCM;满 6400 字节就发一帧。
func consume(pcm: Data) {
pending.append(pcm)
while pending.count >= chunkBytes {
let chunk = pending.prefix(chunkBytes)
pending.removeFirst(chunkBytes)
send(VolcFrame.build(.audioOnlyRequest, .positiveSequence, .none, payload: Data(chunk), sequence: allocSeq()))
}
}

/// 停止说话:把剩余音频发出,再发负序号末帧收尾,等服务端回 final。
func finish() {
if !pending.isEmpty {
send(VolcFrame.build(.audioOnlyRequest, .positiveSequence, .none, payload: pending, sequence: allocSeq()))
pending.removeAll()
}
let finalSeq = -nextSeq
nextSeq += 1
send(VolcFrame.build(.audioOnlyRequest, .negativeSequence, .none, payload: Data(), sequence: finalSeq))
}

func cancel() {
finished = true
task?.cancel(with: .goingAway, reason: nil)
task = nil
}

// MARK: - 内部

private func allocSeq() -> Int32 { let s = nextSeq; nextSeq += 1; return s }

private func send(_ data: Data) {
task?.send(.data(data)) { [weak self] err in
if let err { self?.fail("发送失败:\(err.localizedDescription)") }
}
}

private func receiveLoop() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure(let err):
// 断连/出错:有 partial 就兜底回 final,不丢已识别文字。
if self.finished { return }
if !self.lastPartial.isEmpty {
self.finished = true
self.onFinal?(self.lastPartial)
} else {
self.fail(err.localizedDescription)
}
case .success(let msg):
if case .data(let data) = msg { self.handle(data) }
if !self.finished { self.receiveLoop() }
}
}
}

private func handle(_ data: Data) {
guard let parsed = VolcFrame.parse(data) else { return }

if parsed.messageType == .errorMessage {
let body = String(data: parsed.payload, encoding: .utf8) ?? ""
fail("ASR \(parsed.errorCode ?? 0):\(body)")
return
}
guard parsed.messageType == .fullServerResponse else { return }
guard let json = try? JSONSerialization.jsonObject(with: parsed.payload) as? [String: Any] else { return }

let text = Self.extractText(json)
if parsed.isFinal {
finished = true
onFinal?(text.isEmpty ? lastPartial : text)
} else if !text.isEmpty {
lastPartial = text
onPartial?(text)
}
}

private func fail(_ msg: String) {
guard !finished else { return }
finished = true
onError?(msg)
}

/// 优先用 utterances 拼接(含全部分段),否则取 result.text。
private static func extractText(_ json: [String: Any]) -> String {
var result: [String: Any]?
if let r = json["result"] as? [String: Any] {
result = r
} else if let arr = json["result"] as? [[String: Any]], let first = arr.first {
result = first
} else if json["text"] is String {
result = json
}
guard let result else { return "" }
if let utts = result["utterances"] as? [[String: Any]] {
let pieces = utts.compactMap { $0["text"] as? String }
if !pieces.isEmpty { return pieces.joined() }
}
return (result["text"] as? String) ?? ""
}
}
Loading
Loading