feat:mp3 (#3)
--------- Co-authored-by: intelligence <intelligence@debian.local> Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
1 parent
32e7f32e0a
commit
64b9d704fb
25 files changed
+488
-98
No files matched your search
@@ -145,8 +145,12 @@ final class AppModel: ObservableObject {
|
||||
Task {
|
||||
defer { try? FileManager.default.removeItem(at: recording) }
|
||||
do {
|
||||
let encoded = try await Task.detached(priority: .userInitiated) {
|
||||
try MP3Encoder.encode(wav: recording)
|
||||
}.value
|
||||
defer { try? FileManager.default.removeItem(at: encoded) }
|
||||
let text = try await transcriptionClient.transcribe(
|
||||
recording: recording,
|
||||
recording: encoded,
|
||||
plan: transcriptionPlan,
|
||||
prompt: settings.prompt
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
enum MP3EncodingError: LocalizedError {
|
||||
case invalidRecording
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidRecording: "The recording could not be read."
|
||||
case .encodingFailed: "The recording could not be compressed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MP3Encoder {
|
||||
static func encode(wav url: URL) throws -> URL {
|
||||
let wav = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
let pcm = try pcmData(fromWAV: wav)
|
||||
var encodedBytes: UnsafeMutablePointer<UInt8>?
|
||||
var encodedSize = 0
|
||||
let result = pcm.withUnsafeBytes { bytes in
|
||||
transui_mp3_encode_mono_16k_32kbps(
|
||||
bytes.bindMemory(to: UInt8.self).baseAddress,
|
||||
bytes.count,
|
||||
&encodedBytes,
|
||||
&encodedSize
|
||||
)
|
||||
}
|
||||
guard result == TRANSUI_MP3_OK, let encodedBytes, encodedSize > 0 else {
|
||||
transui_mp3_free(encodedBytes)
|
||||
throw MP3EncodingError.encodingFailed
|
||||
}
|
||||
defer { transui_mp3_free(encodedBytes) }
|
||||
|
||||
let output = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("transui-\(UUID().uuidString).mp3")
|
||||
try Data(bytes: encodedBytes, count: encodedSize).write(to: output, options: .atomic)
|
||||
return output
|
||||
}
|
||||
|
||||
static func pcmData(fromWAV wav: Data) throws -> Data {
|
||||
guard wav.count >= 12,
|
||||
String(decoding: wav[0..<4], as: UTF8.self) == "RIFF",
|
||||
String(decoding: wav[8..<12], as: UTF8.self) == "WAVE"
|
||||
else { throw MP3EncodingError.invalidRecording }
|
||||
|
||||
var formatIsSupported = false
|
||||
var audio: Data?
|
||||
var offset = 12
|
||||
while offset <= wav.count - 8 {
|
||||
let chunkName = String(decoding: wav[offset..<(offset + 4)], as: UTF8.self)
|
||||
let chunkSize = Int(readUInt32(wav, at: offset + 4))
|
||||
let contentOffset = offset + 8
|
||||
guard chunkSize >= 0, contentOffset <= wav.count,
|
||||
chunkSize <= wav.count - contentOffset
|
||||
else { throw MP3EncodingError.invalidRecording }
|
||||
|
||||
if chunkName == "fmt ", chunkSize >= 16 {
|
||||
formatIsSupported = readUInt16(wav, at: contentOffset) == 1
|
||||
&& readUInt16(wav, at: contentOffset + 2) == 1
|
||||
&& readUInt32(wav, at: contentOffset + 4) == 16_000
|
||||
&& readUInt16(wav, at: contentOffset + 14) == 16
|
||||
} else if chunkName == "data" {
|
||||
audio = wav.subdata(in: contentOffset..<(contentOffset + chunkSize))
|
||||
}
|
||||
|
||||
let paddedSize = chunkSize + (chunkSize & 1)
|
||||
guard paddedSize <= Int.max - contentOffset else {
|
||||
throw MP3EncodingError.invalidRecording
|
||||
}
|
||||
offset = contentOffset + paddedSize
|
||||
}
|
||||
|
||||
guard formatIsSupported, let audio, !audio.isEmpty, audio.count.isMultiple(of: 2) else {
|
||||
throw MP3EncodingError.invalidRecording
|
||||
}
|
||||
return audio
|
||||
}
|
||||
|
||||
private static func readUInt16(_ data: Data, at offset: Int) -> UInt16 {
|
||||
UInt16(data[offset]) | UInt16(data[offset + 1]) << 8
|
||||
}
|
||||
|
||||
private static func readUInt32(_ data: Data, at offset: Int) -> UInt32 {
|
||||
UInt32(data[offset])
|
||||
| UInt32(data[offset + 1]) << 8
|
||||
| UInt32(data[offset + 2]) << 16
|
||||
| UInt32(data[offset + 3]) << 24
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,8 @@ struct TranscriptionClient {
|
||||
if !model.isEmpty { field("model", model) }
|
||||
if !prompt.isEmpty { field("prompt", prompt) }
|
||||
data.append("--\(boundary)\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n")
|
||||
data.append("Content-Type: audio/wav\r\n\r\n")
|
||||
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.mp3\"\r\n")
|
||||
data.append("Content-Type: audio/mpeg\r\n\r\n")
|
||||
data.append(try Data(contentsOf: recording))
|
||||
data.append("\r\n--\(boundary)--\r\n")
|
||||
return data
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#include "transui_mp3.h"
|
||||
@@ -0,0 +1,45 @@
|
||||
import XCTest
|
||||
|
||||
@testable import Transui
|
||||
|
||||
final class MP3EncoderTests: XCTestCase {
|
||||
func testExtractsSupportedPCMDataFromWAV() throws {
|
||||
let samples = Data([0x01, 0x02, 0x03, 0x04])
|
||||
let wav = makeWAV(samples: samples)
|
||||
|
||||
XCTAssertEqual(try MP3Encoder.pcmData(fromWAV: wav), samples)
|
||||
}
|
||||
|
||||
func testRejectsWrongSampleRate() {
|
||||
XCTAssertThrowsError(try MP3Encoder.pcmData(fromWAV: makeWAV(sampleRate: 44_100)))
|
||||
}
|
||||
|
||||
private func makeWAV(sampleRate: UInt32 = 16_000, samples: Data = Data([0, 0])) -> Data {
|
||||
var data = Data("RIFF".utf8)
|
||||
append(UInt32(36 + samples.count), to: &data)
|
||||
data.append(Data("WAVEfmt ".utf8))
|
||||
append(UInt32(16), to: &data)
|
||||
append(UInt16(1), to: &data)
|
||||
append(UInt16(1), to: &data)
|
||||
append(sampleRate, to: &data)
|
||||
append(sampleRate * 2, to: &data)
|
||||
append(UInt16(2), to: &data)
|
||||
append(UInt16(16), to: &data)
|
||||
data.append(Data("data".utf8))
|
||||
append(UInt32(samples.count), to: &data)
|
||||
data.append(samples)
|
||||
return data
|
||||
}
|
||||
|
||||
private func append(_ value: UInt16, to data: inout Data) {
|
||||
data.append(UInt8(value & 0xff))
|
||||
data.append(UInt8(value >> 8))
|
||||
}
|
||||
|
||||
private func append(_ value: UInt32, to data: inout Data) {
|
||||
data.append(UInt8(value & 0xff))
|
||||
data.append(UInt8((value >> 8) & 0xff))
|
||||
data.append(UInt8((value >> 16) & 0xff))
|
||||
data.append(UInt8((value >> 24) & 0xff))
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ targets:
|
||||
deploymentTarget: "15.0"
|
||||
sources:
|
||||
- path: Sources/Transui
|
||||
- path: ../third_party/transui_mp3
|
||||
- path: ../third_party/shine/src/lib
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_ENTITLEMENTS: Transui.entitlements
|
||||
@@ -32,6 +34,11 @@ targets:
|
||||
INFOPLIST_FILE: Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos
|
||||
PRODUCT_NAME: Transui
|
||||
GCC_C_LANGUAGE_STANDARD: c11
|
||||
HEADER_SEARCH_PATHS:
|
||||
- "$(SRCROOT)/../third_party/transui_mp3"
|
||||
- "$(SRCROOT)/../third_party/shine/src/lib"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: Sources/Transui/Transui-Bridging-Header.h
|
||||
|
||||
TransuiTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
Reference in new issue
Block a user