From ac43fec3888420007c769f0376515a3f24e78f0e Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Mon, 27 Jul 2026 15:45:38 +0900 Subject: [PATCH 1/3] feat(audio): save recordings as Ogg Opus --- README.md | 6 +- apps/wisp-desktop/src/app.rs | 6 +- apps/wisp-desktop/src/library.rs | 10 +- apps/wisp-desktop/src/main.rs | 2 +- apps/wisp-desktop/src/session_updates.rs | 18 +- apps/wisp-desktop/src/transcript_export.rs | 6 +- crates/wisp-audiokit/src/lib.rs | 2 +- crates/wisp-core/src/transcript.rs | 6 +- crates/wisp-storage/src/lib.rs | 8 +- crates/wisp-storage/src/segments.rs | 4 +- crates/wisp-storage/src/sessions.rs | 4 +- .../Sources/WispAudioKit/Bridge.swift | 2 +- .../WispAudioKit/OpusOggRecorder.swift | 474 ++++++++++++++++++ .../WispAudioKit/TranscriptionPipeline.swift | 48 +- .../Sources/WispAudioKit/WispSession.swift | 22 +- .../WispAudioKit/Sources/wispctl/main.swift | 10 +- .../WispAudioKitTests/WispSessionTests.swift | 100 +++- 17 files changed, 640 insertions(+), 88 deletions(-) create mode 100644 native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift diff --git a/README.md b/README.md index 6ee8747..b9397f7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Wisp captures your microphone and system audio (the other side of a call) at the - **On-device transcription** — Uses [`SpeechAnalyzer`](https://developer.apple.com/documentation/speech), the new API in Apple's Speech framework on macOS. Windows preview builds can use `Windows.Media.SpeechRecognition` or prepare a local model from setup. - **System audio + microphone capture** — Uses macOS 14.4+ [Core Audio Process Taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) to tap meeting-app output without prompts, mixes it with your mic input, and merges both sides into a single transcript. Windows local-model work is structured around WASAPI mic + loopback capture. - **Built in Rust with a GPU-rendered UI** — The UI is built on [GPUI](https://www.gpui.rs/), the framework that powers the [Zed](https://zed.dev/) editor. Native-feeling responsiveness and smooth scrolling. -- **Simple local storage** — Recordings are stored as WAV and metadata as SQLite under `~/Library/Application Support/dev.mokmok.wisp/`. Easy to export and analyze later. +- **Simple local storage** — Recordings are stored as Ogg/Opus and metadata as SQLite under `~/Library/Application Support/dev.mokmok.wisp/`. Easy to export and analyze later. ## Screenshots @@ -43,7 +43,7 @@ Core Audio Process Tap ─┐ Microphone input ───────┘ │ ▲ └─► SpeechAnalyzer ────────────┘ │ - └─► wisp-storage (SQLite + WAV) + └─► wisp-storage (SQLite + Ogg/Opus) ``` ## Requirements @@ -93,7 +93,7 @@ directory are stored. When unset, Wisp uses `~/Library/Application Support/dev.mokmok.wisp`. If a completed transcript cannot be committed to SQLite, Wisp writes an -atomic `transcript-recovery.json` beside that session's WAV files, blocks a +atomic `transcript-recovery.json` beside that session's Ogg files, blocks a new recording, and retries reconciliation immediately or on the next launch. Wisp exits before recording if the durable database cannot be opened; it never treats an in-memory fallback as successful persistence. diff --git a/apps/wisp-desktop/src/app.rs b/apps/wisp-desktop/src/app.rs index ec6ac0e..f416a95 100644 --- a/apps/wisp-desktop/src/app.rs +++ b/apps/wisp-desktop/src/app.rs @@ -279,7 +279,7 @@ pub struct AppModel { pub current_session_started_at: Option>, pub current_session_dir_name: Option, /// Per-run audio directory. Retained with the transcript after a storage - /// failure so a durable recovery snapshot can be written beside the WAVs. + /// failure so a durable recovery snapshot can be written beside the Ogg files. pub current_output_dir: Option, /// The session being viewed in `View::History`, kept around so the /// header can render its title without re-querying. @@ -647,8 +647,8 @@ mod tests { started_at, ended_at: Some(started_at), title: format!("session {id}"), - mic_wav_path: format!("session-{id}/mic.wav"), - system_wav_path: format!("session-{id}/system.wav"), + mic_wav_path: format!("session-{id}/mic.ogg"), + system_wav_path: format!("session-{id}/system.ogg"), notes: String::new(), } } diff --git a/apps/wisp-desktop/src/library.rs b/apps/wisp-desktop/src/library.rs index 2a0e48c..10448f5 100644 --- a/apps/wisp-desktop/src/library.rs +++ b/apps/wisp-desktop/src/library.rs @@ -45,7 +45,7 @@ pub fn session_dir_name(started_at: DateTime) -> String { } /// Create a new session row. `dir_name` is the per-session subdirectory -/// passed to the Swift audio kit beneath the `recordings` directory. WAV +/// passed to the Swift audio kit beneath the `recordings` directory. Ogg/Opus /// paths are stored relative to the storage root, as required by /// `wisp_core::Session`. pub fn create_session( @@ -53,8 +53,8 @@ pub fn create_session( started_at: DateTime, dir_name: &str, ) -> Result { - let mic_rel = format!("recordings/{dir_name}/mic.wav"); - let sys_rel = format!("recordings/{dir_name}/system.wav"); + let mic_rel = format!("recordings/{dir_name}/mic.ogg"); + let sys_rel = format!("recordings/{dir_name}/system.ogg"); storage.sessions().create(&NewSession { started_at, title: default_title(started_at), @@ -169,11 +169,11 @@ mod tests { let output_dir = storage_root.join("recordings").join(&dir_name); assert_eq!( storage_root.join(&session.mic_wav_path), - output_dir.join("mic.wav") + output_dir.join("mic.ogg") ); assert_eq!( storage_root.join(&session.system_wav_path), - output_dir.join("system.wav") + output_dir.join("system.ogg") ); assert_eq!(session.started_at, started_at); } diff --git a/apps/wisp-desktop/src/main.rs b/apps/wisp-desktop/src/main.rs index 65afc91..8831b54 100644 --- a/apps/wisp-desktop/src/main.rs +++ b/apps/wisp-desktop/src/main.rs @@ -503,7 +503,7 @@ pub(crate) fn toggle_recording( if !setup_complete { return; } - // Per-session subdirectory so each recording's WAVs stay + // Per-session subdirectory so each recording's Ogg files stay // grouped and we can show them as a single library row. let started_at = Utc::now(); let dir_name = library::session_dir_name(started_at); diff --git a/apps/wisp-desktop/src/session_updates.rs b/apps/wisp-desktop/src/session_updates.rs index fbbd96a..e326b0f 100644 --- a/apps/wisp-desktop/src/session_updates.rs +++ b/apps/wisp-desktop/src/session_updates.rs @@ -183,8 +183,8 @@ fn resolve_session_id( .get(session_id) .map_err(|error| format!("could not validate the session row: {error}"))? { - let expected_mic = format!("recordings/{dir_name}/mic.wav"); - let expected_system = format!("recordings/{dir_name}/system.wav"); + let expected_mic = format!("recordings/{dir_name}/mic.ogg"); + let expected_system = format!("recordings/{dir_name}/system.ogg"); if session.mic_wav_path != expected_mic || session.system_wav_path != expected_system { return Err("the retained session id belongs to a different recording".into()); } @@ -242,7 +242,7 @@ fn delete_unstarted_session( true } -/// Write the in-memory transcript beside its WAV files using an atomic +/// Write the in-memory transcript beside its Ogg files using an atomic /// replace. This is the durable fallback used before quit and whenever the /// database transaction rolls back. pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result { @@ -310,7 +310,7 @@ pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result { /// new recording can start. Valid snapshots are retried automatically against /// `SQLite`. If storage is still unavailable, the first pending transcript is /// restored into `AppModel`'s guarded Failed state so the existing Retry Save -/// action remains available; its sidecar stays beside the WAV files. +/// action remains available; its sidecar stays beside the Ogg files. pub(crate) fn recover_pending_sessions( model: &mut AppModel, storage: &SharedStorage, @@ -469,8 +469,8 @@ fn load_recovery_model( .current_session_dir_name .as_deref() .expect("validated directory metadata"); - let expected_mic = format!("recordings/{dir_name}/mic.wav"); - let expected_system = format!("recordings/{dir_name}/system.wav"); + let expected_mic = format!("recordings/{dir_name}/mic.ogg"); + let expected_system = format!("recordings/{dir_name}/system.ogg"); if session.mic_wav_path != expected_mic || session.system_wav_path != expected_system { @@ -602,8 +602,8 @@ mod tests { .create(&wisp_core::NewSession { started_at, title: library::default_title(started_at), - mic_wav_path: format!("recordings/{dir_name}/mic.wav"), - system_wav_path: format!("recordings/{dir_name}/system.wav"), + mic_wav_path: format!("recordings/{dir_name}/mic.ogg"), + system_wav_path: format!("recordings/{dir_name}/system.ogg"), }) .expect("preallocate session") } @@ -652,7 +652,7 @@ mod tests { assert_eq!(session.started_at, started_at); assert_eq!( session.mic_wav_path, - format!("recordings/{dir_name}/mic.wav") + format!("recordings/{dir_name}/mic.ogg") ); } diff --git a/apps/wisp-desktop/src/transcript_export.rs b/apps/wisp-desktop/src/transcript_export.rs index 244a326..b897bb8 100644 --- a/apps/wisp-desktop/src/transcript_export.rs +++ b/apps/wisp-desktop/src/transcript_export.rs @@ -77,7 +77,7 @@ struct TranscriptEnvelope<'a> { #[serde(rename = "type")] event_type: &'static str, /// CloudEvents `source`: a stable, machine-independent producer id. - /// Deliberately no hostname, absolute paths, or WAV locations, to honour + /// Deliberately no hostname, absolute paths, or audio locations, to honour /// Wisp's offline / privacy-first promise. source: &'static str, #[serde(skip_serializing_if = "Option::is_none")] @@ -335,8 +335,8 @@ mod tests { .expect("valid end timestamp"), ), title: title.to_string(), - mic_wav_path: "mic.wav".to_string(), - system_wav_path: "system.wav".to_string(), + mic_wav_path: "mic.ogg".to_string(), + system_wav_path: "system.ogg".to_string(), notes: String::new(), } } diff --git a/crates/wisp-audiokit/src/lib.rs b/crates/wisp-audiokit/src/lib.rs index ee0dad4..da6c0ef 100644 --- a/crates/wisp-audiokit/src/lib.rs +++ b/crates/wisp-audiokit/src/lib.rs @@ -391,7 +391,7 @@ mod imp { impl Session { /// Construct a new session. Does no I/O — call [`Self::start`] next. /// - /// `output_dir` is the directory in which the per-session WAV files + /// `output_dir` is the directory in which the per-session Ogg files /// will be written (created if needed). `locale` is a BCP-47 /// language tag passed to the Swift speech recognizer /// (e.g. `"ja-JP"`). diff --git a/crates/wisp-core/src/transcript.rs b/crates/wisp-core/src/transcript.rs index fbd7b55..4897321 100644 --- a/crates/wisp-core/src/transcript.rs +++ b/crates/wisp-core/src/transcript.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{SegmentId, SessionId, SourceLabel}; /// One recording session = one meeting. Tracks lifecycle timestamps, the -/// user-editable title, and the on-disk paths to the captured WAV files. +/// user-editable title, and the on-disk paths to the captured Ogg/Opus files. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Session { pub id: SessionId, @@ -14,7 +14,9 @@ pub struct Session { pub title: String, /// Path relative to the storage root, never absolute. Lets the user /// move their library between machines without DB rewrites. + /// Ogg/Opus path. The field name is retained for database compatibility. pub mic_wav_path: String, + /// Ogg/Opus path. The field name is retained for database compatibility. pub system_wav_path: String, pub notes: String, } @@ -25,7 +27,9 @@ pub struct Session { pub struct NewSession { pub started_at: DateTime, pub title: String, + /// Ogg/Opus path. The field name is retained for database compatibility. pub mic_wav_path: String, + /// Ogg/Opus path. The field name is retained for database compatibility. pub system_wav_path: String, } diff --git a/crates/wisp-storage/src/lib.rs b/crates/wisp-storage/src/lib.rs index bbea3d6..3c511c4 100644 --- a/crates/wisp-storage/src/lib.rs +++ b/crates/wisp-storage/src/lib.rs @@ -1,7 +1,7 @@ //! Persistence layer for Wisp sessions. //! //! Owns the `SQLite` schema for sessions, transcript segments, and the -//! filesystem layout that pairs each session with its source WAV files. +//! filesystem layout that pairs each session with its source Ogg/Opus files. //! //! Connection model: a single, owned `rusqlite::Connection` lives inside //! [`Storage`]. `SQLite` serializes writers internally; the desktop app is @@ -24,7 +24,7 @@ pub use crate::segments::Segments; pub use crate::sessions::Sessions; /// Owns the database connection and the on-disk root that holds the `SQLite` -/// file plus session WAV directories. +/// file plus session Ogg/Opus directories. pub struct Storage { conn: Connection, root: PathBuf, @@ -157,8 +157,8 @@ mod tests { NewSession { started_at: Utc.with_ymd_and_hms(2026, 7, 15, 10, 0, 0).unwrap(), title: "transaction test".into(), - mic_wav_path: "transaction-test/mic.wav".into(), - system_wav_path: "transaction-test/system.wav".into(), + mic_wav_path: "transaction-test/mic.ogg".into(), + system_wav_path: "transaction-test/system.ogg".into(), } } diff --git a/crates/wisp-storage/src/segments.rs b/crates/wisp-storage/src/segments.rs index a146406..2c071de 100644 --- a/crates/wisp-storage/src/segments.rs +++ b/crates/wisp-storage/src/segments.rs @@ -128,8 +128,8 @@ mod tests { .create(&NewSession { started_at: Utc.with_ymd_and_hms(2026, 5, 28, 10, 0, 0).unwrap(), title: "test".into(), - mic_wav_path: "s/mic.wav".into(), - system_wav_path: "s/system.wav".into(), + mic_wav_path: "s/mic.ogg".into(), + system_wav_path: "s/system.ogg".into(), }) .expect("create session") } diff --git a/crates/wisp-storage/src/sessions.rs b/crates/wisp-storage/src/sessions.rs index 187e5e3..2ba7ff2 100644 --- a/crates/wisp-storage/src/sessions.rs +++ b/crates/wisp-storage/src/sessions.rs @@ -154,8 +154,8 @@ mod tests { NewSession { started_at: Utc.with_ymd_and_hms(2026, 5, 28, 10, 30, 0).unwrap(), title: title.into(), - mic_wav_path: "session-1/mic.wav".into(), - system_wav_path: "session-1/system.wav".into(), + mic_wav_path: "session-1/mic.ogg".into(), + system_wav_path: "session-1/system.ogg".into(), } } diff --git a/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift b/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift index b6cdda9..d7c3570 100644 --- a/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift +++ b/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift @@ -197,7 +197,7 @@ private func unbox(_ p: OpaquePointer?) -> SessionHandle? { /// /// On failure returns `nil`; the error is not stored because there is no /// handle to hold it. Errors are limited to output-directory setup (including -/// refusing to overwrite an existing WAV file) and "input pointer was NULL". +/// refusing to overwrite an existing Ogg file) and "input pointer was NULL". @_cdecl("wisp_session_new") public func wisp_session_new( output_dir: UnsafePointer?, diff --git a/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift b/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift new file mode 100644 index 0000000..b63af96 --- /dev/null +++ b/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift @@ -0,0 +1,474 @@ +@preconcurrency import AVFoundation +import AudioToolbox +import Foundation +import os.lock + +/// Encodes PCM buffers as Opus on a background task and writes a single, +/// continuously playable Ogg logical stream. +/// +/// Every completed Opus packet is emitted immediately as its own Ogg page. +/// This costs a small amount of container overhead, but a crash loses at most +/// the packet currently being encoded. A missing EOS flag does not invalidate +/// the pages already present in the file. +final class OpusOggRecorder: @unchecked Sendable { + private static let queueCapacity = 256 + + private let continuation: AsyncStream.Continuation + private let encodingTask: Task + private let droppedBuffers = OSAllocatedUnfairLock(initialState: 0) + + init(url: URL, sourceFormat: AVAudioFormat) throws { + let channelCount = min(max(sourceFormat.channelCount, 1), 2) + let encoder = try OpusEncoder(url: url, channelCount: channelCount) + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(Self.queueCapacity) + ) + self.continuation = continuation + encodingTask = Task.detached(priority: .utility) { + do { + for await buffer in stream { + try encoder.encode(buffer) + } + try encoder.finish() + } catch { + wispLog("[OGG] encoder error for \(url.lastPathComponent): \(error)") + try? encoder.closeAfterError() + } + } + } + + /// Copies and queues a callback-owned PCM buffer without performing codec + /// or file I/O on the real-time audio thread. + func push(_ buffer: AVAudioPCMBuffer) { + guard let copy = buffer.detachedCopy() else { + droppedBuffers.withLock { $0 += 1 } + return + } + switch continuation.yield(copy) { + case .enqueued: + break + case .dropped: + droppedBuffers.withLock { $0 += 1 } + case .terminated: + break + @unknown default: + droppedBuffers.withLock { $0 += 1 } + } + } + + /// Stops accepting PCM, drains the bounded queue, flushes the Opus + /// converter, writes EOS, and closes the file. + func finish() async { + continuation.finish() + await encodingTask.value + let dropped = droppedBuffers.withLock { $0 } + if dropped > 0 { + wispLog("[OGG] dropped \(dropped) buffered audio chunks") + } + } +} + +private final class OpusEncoder: @unchecked Sendable { + private static let outputSampleRate = 48_000.0 + private static let bitRatePerChannel = 32_000 + + private let channelCount: AVAudioChannelCount + private let outputFormat: AVAudioFormat + private let writer: OggOpusWriter + private var converter: AVAudioConverter? + private var converterInputFormat: AVAudioFormat? + private var inputSamplesAt48k = 0.0 + private var isClosed = false + + init(url: URL, channelCount: AVAudioChannelCount) throws { + self.channelCount = channelCount + guard let outputFormat = AVAudioFormat(settings: [ + AVFormatIDKey: kAudioFormatOpus, + AVSampleRateKey: Self.outputSampleRate, + AVNumberOfChannelsKey: channelCount, + ]) else { + throw PoCError.converterCreationFailed + } + self.outputFormat = outputFormat + + // Apple's Opus encoder reports the codec look-ahead through + // `primeInfo`. It is normally 312 samples at 48 kHz. + guard let probe = AVAudioConverter( + from: OpusEncoder.normalizedPCMFormat(for: channelCount), + to: outputFormat + ) else { + throw PoCError.converterCreationFailed + } + probe.bitRate = Self.bitRatePerChannel * Int(channelCount) + let preSkip = UInt16(clamping: probe.primeInfo.leadingFrames) + writer = try OggOpusWriter( + url: url, + channelCount: UInt8(channelCount), + preSkip: preSkip + ) + } + + func encode(_ buffer: AVAudioPCMBuffer) throws { + guard !isClosed, buffer.frameLength > 0 else { return } + inputSamplesAt48k += Double(buffer.frameLength) + * Self.outputSampleRate / buffer.format.sampleRate + + let converter = try converter(for: buffer.format) + let input = ConverterInput(buffer) + try drain(converter: converter) { _, status in + input.provide(status: status) + } + } + + func finish() throws { + guard !isClosed else { return } + if let converter { + try drain(converter: converter) { _, status in + status.pointee = .endOfStream + return nil + } + } + let finalGranule = writer.preSkip + UInt64(inputSamplesAt48k.rounded()) + try writer.finish(finalGranule: finalGranule) + isClosed = true + } + + func closeAfterError() throws { + guard !isClosed else { return } + // Do not manufacture EOS after an encoding error. Closing here leaves + // the already completed Ogg pages usable as a truncated recording. + try writer.closeTruncated() + isClosed = true + } + + private func converter(for inputFormat: AVAudioFormat) throws -> AVAudioConverter { + if let converter, converterInputFormat?.isEqual(inputFormat) == true { + return converter + } + if let converter { + // A device switch can change the callback's PCM format. Flush the + // old codec before replacing it so its final buffered packet is + // not silently lost from the continuous Ogg stream. + try drain(converter: converter) { _, status in + status.pointee = .endOfStream + return nil + } + } + guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { + throw PoCError.converterCreationFailed + } + converter.bitRate = Self.bitRatePerChannel * Int(channelCount) + self.converter = converter + converterInputFormat = inputFormat + return converter + } + + private func drain( + converter: AVAudioConverter, + inputBlock: @escaping AVAudioConverterInputBlock + ) throws { + while true { + let output = AVAudioCompressedBuffer( + format: outputFormat, + packetCapacity: 1, + maximumPacketSize: converter.maximumOutputPacketSize + ) + var error: NSError? + let status = converter.convert( + to: output, + error: &error, + withInputFrom: inputBlock + ) + if let error { throw error } + if output.packetCount > 0, output.byteLength > 0 { + let packet = Data(bytes: output.data, count: Int(output.byteLength)) + try writer.writeAudioPacket( + packet, + sampleCount: OpusPacket.sampleCount(packet) + ) + } + switch status { + case .haveData: + continue + case .inputRanDry, .endOfStream: + return + case .error: + throw PoCError.converterCreationFailed + @unknown default: + return + } + } + } + + private static func normalizedPCMFormat( + for channels: AVAudioChannelCount + ) -> AVAudioFormat { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: outputSampleRate, + channels: channels, + interleaved: false + )! + } +} + +private final class ConverterInput: @unchecked Sendable { + private let buffer: AVAudioPCMBuffer + private var supplied = false + + init(_ buffer: AVAudioPCMBuffer) { + self.buffer = buffer + } + + func provide( + status: UnsafeMutablePointer + ) -> AVAudioBuffer? { + if supplied { + status.pointee = .noDataNow + return nil + } + supplied = true + status.pointee = .haveData + return buffer + } +} + +private enum OpusPacket { + /// Returns an Opus packet's decoded duration in the mandatory 48 kHz Ogg + /// granule timebase (RFC 6716, section 3.1). + static func sampleCount(_ packet: Data) -> UInt64 { + guard let toc = packet.first else { return 0 } + let config = toc >> 3 + let samplesPerFrame: UInt64 + if config >= 16 { + samplesPerFrame = 120 << UInt64(config & 0x03) + } else if config >= 12 { + samplesPerFrame = 480 << UInt64(config & 0x01) + } else if config & 0x03 == 0x03 { + samplesPerFrame = 2_880 + } else { + samplesPerFrame = 480 << UInt64(config & 0x03) + } + + let frameCode = toc & 0x03 + let frameCount: UInt64 + switch frameCode { + case 0: + frameCount = 1 + case 1, 2: + frameCount = 2 + default: + frameCount = packet.count > 1 ? UInt64(packet[packet.startIndex + 1] & 0x3f) : 0 + } + return min(samplesPerFrame * frameCount, 5_760) + } +} + +private final class OggOpusWriter { + let preSkip: UInt64 + + private let file: FileHandle + private let serialNumber: UInt32 + private var sequenceNumber: UInt32 = 0 + private var encodedGranule: UInt64 = 0 + private var bytesWritten: UInt64 = 0 + private var audioPagesSinceSync = 0 + private var lastAudioPage: ( + offset: UInt64, + sequence: UInt32, + packet: Data, + startGranule: UInt64, + endGranule: UInt64 + )? + private var isClosed = false + + init(url: URL, channelCount: UInt8, preSkip: UInt16) throws { + guard FileManager.default.createFile(atPath: url.path, contents: nil), + let file = FileHandle(forWritingAtPath: url.path) + else { + throw CocoaError(.fileWriteUnknown) + } + self.file = file + serialNumber = UInt32.random(in: UInt32.min ... UInt32.max) + self.preSkip = UInt64(preSkip) + + var head = Data("OpusHead".utf8) + head.append(1) // version + head.append(channelCount) + head.appendLittleEndian(preSkip) + head.appendLittleEndian(UInt32(48_000)) + head.appendLittleEndian(Int16(0)) // output gain + head.append(0) // channel mapping family 0 (mono/stereo) + try writePage(packet: head, granule: 0, flags: 0x02) // BOS + + let vendor = Data("WispAudioKit".utf8) + var tags = Data("OpusTags".utf8) + tags.appendLittleEndian(UInt32(vendor.count)) + tags.append(vendor) + tags.appendLittleEndian(UInt32(0)) // user comment count + try writePage(packet: tags, granule: 0, flags: 0) + try file.synchronize() + } + + func writeAudioPacket(_ packet: Data, sampleCount: UInt64) throws { + guard !isClosed, !packet.isEmpty else { return } + let startGranule = encodedGranule + encodedGranule += sampleCount + let page = try writePage(packet: packet, granule: encodedGranule, flags: 0) + lastAudioPage = ( + offset: page.offset, + sequence: page.sequence, + packet: packet, + startGranule: startGranule, + endGranule: encodedGranule + ) + audioPagesSinceSync += 1 + // Opus normally produces 20 ms packets. Sync about once per second so + // even a machine-level crash has a bounded durability window; this + // runs on the utility encoder task, never the audio callback. + if audioPagesSinceSync >= 50 { + try file.synchronize() + audioPagesSinceSync = 0 + } + } + + func finish(finalGranule: UInt64) throws { + guard !isClosed else { return } + if let lastAudioPage { + let trimmedGranule = min( + lastAudioPage.endGranule, + max(lastAudioPage.startGranule, finalGranule) + ) + let eosPage = try makePage( + packet: lastAudioPage.packet, + granule: trimmedGranule, + flags: 0x04, // EOS + sequence: lastAudioPage.sequence + ) + try file.seek(toOffset: lastAudioPage.offset) + try file.write(contentsOf: eosPage) + } else { + _ = try writePage(packet: Data(), granule: 0, flags: 0x04) + } + try file.synchronize() + try file.close() + isClosed = true + } + + func closeTruncated() throws { + guard !isClosed else { return } + // All completed packets have already been written. Leaving the final + // page without EOS is intentional: readers can recover through it. + try file.synchronize() + try file.close() + isClosed = true + } + + @discardableResult + private func writePage( + packet: Data, + granule: UInt64, + flags: UInt8 + ) throws -> (offset: UInt64, sequence: UInt32) { + let offset = bytesWritten + let sequence = sequenceNumber + let page = try makePage( + packet: packet, + granule: granule, + flags: flags, + sequence: sequence + ) + try file.write(contentsOf: page) + bytesWritten += UInt64(page.count) + sequenceNumber &+= 1 + return (offset, sequence) + } + + private func makePage( + packet: Data, + granule: UInt64, + flags: UInt8, + sequence: UInt32 + ) throws -> Data { + let quotient = packet.count / 255 + let remainder = packet.count % 255 + let segmentCount = quotient + 1 + guard segmentCount <= 255 else { + throw CocoaError(.fileWriteUnknown) + } + + var page = Data() + page.append(Data("OggS".utf8)) + page.append(0) // stream structure version + page.append(flags) + page.appendLittleEndian(granule) + page.appendLittleEndian(serialNumber) + page.appendLittleEndian(sequence) + page.appendLittleEndian(UInt32(0)) // checksum placeholder + page.append(UInt8(segmentCount)) + if quotient > 0 { + page.append(contentsOf: repeatElement(UInt8(255), count: quotient)) + } + page.append(UInt8(remainder)) + page.append(packet) + + let checksum = page.oggCRC + page.replaceSubrange(22 ..< 26, with: checksum.littleEndianBytes) + return page + } +} + +private extension AVAudioPCMBuffer { + func detachedCopy() -> AVAudioPCMBuffer? { + guard let copy = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: frameLength + ) else { return nil } + copy.frameLength = frameLength + + let source = audioBufferList.pointee + let destination = copy.mutableAudioBufferList + let count = min(Int(source.mNumberBuffers), Int(destination.pointee.mNumberBuffers)) + let sourceBuffers = UnsafeMutableAudioBufferListPointer( + UnsafeMutablePointer(mutating: audioBufferList) + ) + let destinationBuffers = UnsafeMutableAudioBufferListPointer(destination) + for index in 0 ..< count { + guard let sourceData = sourceBuffers[index].mData, + let destinationData = destinationBuffers[index].mData + else { continue } + let byteCount = min( + Int(sourceBuffers[index].mDataByteSize), + Int(destinationBuffers[index].mDataByteSize) + ) + memcpy(destinationData, sourceData, byteCount) + destinationBuffers[index].mDataByteSize = UInt32(byteCount) + } + return copy + } +} + +private extension Data { + mutating func appendLittleEndian(_ value: T) { + append(contentsOf: value.littleEndianBytes) + } + + var oggCRC: UInt32 { + var crc: UInt32 = 0 + for byte in self { + crc ^= UInt32(byte) << 24 + for _ in 0 ..< 8 { + crc = (crc & 0x8000_0000) != 0 + ? (crc << 1) ^ 0x04c1_1db7 + : crc << 1 + } + } + return crc + } +} + +private extension FixedWidthInteger { + var littleEndianBytes: [UInt8] { + withUnsafeBytes(of: littleEndian) { Array($0) } + } +} diff --git a/native/WispAudioKit/Sources/WispAudioKit/TranscriptionPipeline.swift b/native/WispAudioKit/Sources/WispAudioKit/TranscriptionPipeline.swift index 96b7677..dc984ac 100644 --- a/native/WispAudioKit/Sources/WispAudioKit/TranscriptionPipeline.swift +++ b/native/WispAudioKit/Sources/WispAudioKit/TranscriptionPipeline.swift @@ -5,7 +5,7 @@ import os.lock import Speech /// One transcription pipeline = one audio source (mic OR system) feeding a -/// dedicated SpeechAnalyzer, plus a WAV writer at the source's native format. +/// dedicated SpeechAnalyzer, plus a background Ogg/Opus recorder. /// /// The pipeline is intentionally per-source so we get speaker attribution /// for free (mic = "self", system = "other") without ML diarization. @@ -27,7 +27,7 @@ public final class TranscriptionPipeline: @unchecked Sendable { public typealias OnResult = @Sendable (Result) -> Void public let label: String - public let wavURL: URL + public let oggURL: URL public var sourceFormat: AVAudioFormat { converterLock.withLock { $0.sourceFormat } @@ -43,7 +43,7 @@ public final class TranscriptionPipeline: @unchecked Sendable { } private let converterLock: OSAllocatedUnfairLock - private let wavFile: AVAudioFile + private let recorder: OpusOggRecorder private let inputContinuation: AsyncStream.Continuation private let onResult: OnResult private var resultsTask: Task? @@ -51,12 +51,12 @@ public final class TranscriptionPipeline: @unchecked Sendable { public init( label: String, sourceFormat: AVAudioFormat, - wavURL: URL, + oggURL: URL, locale: Locale = Locale(identifier: "ja-JP"), onResult: @escaping OnResult ) async throws { self.label = label - self.wavURL = wavURL + self.oggURL = oggURL self.onResult = onResult // SpeechTranscriber with progressive (streaming) preset @@ -79,23 +79,7 @@ public final class TranscriptionPipeline: @unchecked Sendable { initialState: ConverterState(sourceFormat: sourceFormat, converter: converter) ) - // WAV files require interleaved PCM. AVAudioFile.write() auto-converts - // from the buffer's format to the file's format, so non-interleaved - // captures (like SCKit) get interleaved on write. - guard let wavFormat = AVAudioFormat( - commonFormat: sourceFormat.commonFormat, - sampleRate: sourceFormat.sampleRate, - channels: sourceFormat.channelCount, - interleaved: true - ) else { - throw PoCError.converterCreationFailed - } - wavFile = try AVAudioFile( - forWriting: wavURL, - settings: wavFormat.settings, - commonFormat: wavFormat.commonFormat, - interleaved: true - ) + recorder = try OpusOggRecorder(url: oggURL, sourceFormat: sourceFormat) // AsyncStream feeding the analyzer let (inputStream, inputContinuation) = AsyncStream.makeStream() @@ -120,23 +104,16 @@ public final class TranscriptionPipeline: @unchecked Sendable { wispLog( "[\(label)] pipeline ready — analyzer format sr=\(analyzerFormat.sampleRate) ch=\(analyzerFormat.channelCount) fmt=\(analyzerFormat.commonFormat.rawValue)" ) - wispLog("[\(label)] WAV: \(wavURL.path)") + wispLog("[\(label)] Ogg/Opus: \(oggURL.path)") } - /// Push one audio buffer from the source. Writes to WAV and feeds the - /// analyzer (resampling/format-converting on the fly). + /// Push one audio buffer from the source. Queues it for Ogg/Opus encoding + /// and feeds the analyzer (resampling/format-converting on the fly). /// Safe to call from audio callback threads. public func push(_ buffer: AVAudioPCMBuffer) { - // 1. WAV (native format) - if buffer.format.sampleRate == wavFile.processingFormat.sampleRate, - buffer.format.channelCount == wavFile.processingFormat.channelCount - { - do { - try wavFile.write(from: buffer) - } catch { - wispLog("[\(label)] WAV write error: \(error)") - } - } + // 1. Ogg/Opus. The recorder copies into a bounded queue; codec and + // file I/O stay off the real-time callback thread. + recorder.push(buffer) // 2. Resample to analyzer format let (sourceFormat, converter): (AVAudioFormat, AVAudioConverter) = @@ -209,6 +186,7 @@ public final class TranscriptionPipeline: @unchecked Sendable { /// Stop feeding the analyzer and wait for final results to drain. public func finish() async { + await recorder.finish() inputContinuation.finish() try? await analyzer.finalizeAndFinishThroughEndOfInput() _ = await resultsTask?.result diff --git a/native/WispAudioKit/Sources/WispAudioKit/WispSession.swift b/native/WispAudioKit/Sources/WispAudioKit/WispSession.swift index 6a48d2c..424cd94 100644 --- a/native/WispAudioKit/Sources/WispAudioKit/WispSession.swift +++ b/native/WispAudioKit/Sources/WispAudioKit/WispSession.swift @@ -35,8 +35,8 @@ public final class WispSession: @unchecked Sendable { public typealias OnResult = @Sendable (Result) -> Void public typealias OnLog = @Sendable (String) -> Void - public let micWavURL: URL - public let systemWavURL: URL + public let micOggURL: URL + public let systemOggURL: URL private let locale: Locale private let onResult: OnResult @@ -101,10 +101,10 @@ public final class WispSession: @unchecked Sendable { // Keep these names stable so callers can persist the exact paths. // Refuse to reuse a completed/partial recording directory instead of // silently overwriting its audio. - let micWavURL = outputDir.appendingPathComponent("mic.wav") - let systemWavURL = outputDir.appendingPathComponent("system.wav") - if FileManager.default.fileExists(atPath: micWavURL.path) - || FileManager.default.fileExists(atPath: systemWavURL.path) + let micOggURL = outputDir.appendingPathComponent("mic.ogg") + let systemOggURL = outputDir.appendingPathComponent("system.ogg") + if FileManager.default.fileExists(atPath: micOggURL.path) + || FileManager.default.fileExists(atPath: systemOggURL.path) { throw PoCError.outputFilesAlreadyExist(outputDir.path) } @@ -123,8 +123,8 @@ public final class WispSession: @unchecked Sendable { guard reserved else { throw PoCError.outputFilesAlreadyExist(outputDir.path) } - self.micWavURL = micWavURL - self.systemWavURL = systemWavURL + self.micOggURL = micOggURL + self.systemOggURL = systemOggURL self.locale = locale self.onResult = onResult self.onLog = onLog @@ -216,7 +216,7 @@ public final class WispSession: @unchecked Sendable { let micPipeline = try await TranscriptionPipeline( label: "MIC", sourceFormat: micFormat, - wavURL: micWavURL, + oggURL: micOggURL, locale: locale, onResult: { pipelineResult in onResultLocal(Result( @@ -248,7 +248,7 @@ public final class WispSession: @unchecked Sendable { // 4. System audio capture (Process Tap). Pipeline is built lazily // when the first buffer arrives — we don't know the tap's format // until then. - let sysWavURL = systemWavURL + let sysOggURL = systemOggURL let localeLocal = locale let onLogLocal = onLog let sysStateRef = sysState @@ -273,7 +273,7 @@ public final class WispSession: @unchecked Sendable { let pipeline = try await TranscriptionPipeline( label: "SYS", sourceFormat: format, - wavURL: sysWavURL, + oggURL: sysOggURL, locale: localeLocal, onResult: { pipelineResult in onResultLocal(Result( diff --git a/native/WispAudioKit/Sources/wispctl/main.swift b/native/WispAudioKit/Sources/wispctl/main.swift index 682cf25..07aa821 100644 --- a/native/WispAudioKit/Sources/wispctl/main.swift +++ b/native/WispAudioKit/Sources/wispctl/main.swift @@ -43,8 +43,8 @@ do { exit(1) } -wispLog(" MIC WAV: \(session.micWavURL.path)") -wispLog(" SYS WAV: \(session.systemWavURL.path)") +wispLog(" MIC Ogg/Opus: \(session.micOggURL.path)") +wispLog(" SYS Ogg/Opus: \(session.systemOggURL.path)") do { try await session.start() @@ -59,13 +59,13 @@ await waitForInterrupt() await session.stop() wispLog("Done.") -wispLog(" MIC WAV: \(session.micWavURL.path)") -wispLog(" SYS WAV: \(session.systemWavURL.path)") +wispLog(" MIC Ogg/Opus: \(session.micOggURL.path)") +wispLog(" SYS Ogg/Opus: \(session.systemOggURL.path)") // MARK: - CLI helpers /// Create an isolated directory for one recording beneath the user-selected -/// root. `WispSession` uses stable WAV filenames inside this directory, so the +/// root. `WispSession` uses stable Ogg filenames inside this directory, so the /// UUID suffix prevents rapid or concurrent CLI runs from overwriting files. func createRecordingDirectory( in root: URL, diff --git a/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift b/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift index 34cc532..d480e58 100644 --- a/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift +++ b/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift @@ -1,14 +1,15 @@ import Foundation +import AVFoundation @testable import WispAudioKit import XCTest final class WispSessionTests: XCTestCase { func testInitRejectsExistingMicOutput() throws { - try assertInitRejectsExistingOutput("mic.wav") + try assertInitRejectsExistingOutput("mic.ogg") } func testInitRejectsExistingSystemOutput() throws { - try assertInitRejectsExistingOutput("system.wav") + try assertInitRejectsExistingOutput("system.ogg") } func testConcurrentStopIsIdempotentBeforeStart() async throws { @@ -49,6 +50,37 @@ final class WispSessionTests: XCTestCase { capture.stop() } + func testRecorderWritesContinuousOggOpusPages() async throws { + let outputDir = makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: outputDir) } + let output = outputDir.appendingPathComponent("test.ogg") + let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 48_000, + channels: 1, + interleaved: false + )! + let recorder = try OpusOggRecorder(url: output, sourceFormat: format) + for _ in 0 ..< 4 { + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4_800)! + buffer.frameLength = 4_800 + recorder.push(buffer) + } + await recorder.finish() + + let pages = try parseOggPages(Data(contentsOf: output)) + XCTAssertGreaterThan(pages.count, 3) + XCTAssertEqual(pages[0].flags, 0x02) + XCTAssertEqual(pages[0].packet.prefix(8), Data("OpusHead".utf8)) + XCTAssertEqual(pages[1].packet.prefix(8), Data("OpusTags".utf8)) + XCTAssertEqual(pages.last?.flags, 0x04) + XCTAssertEqual( + pages.map(\.sequence), + Array(0 ..< UInt32(pages.count)) + ) + XCTAssertTrue(pages.allSatisfy(\.hasValidCRC)) + } + func testReentrantBridgeStopReturnsInsteadOfDeadlocking() throws { let outputDir = makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: outputDir) } @@ -97,6 +129,70 @@ final class WispSessionTests: XCTestCase { } } +private struct ParsedOggPage { + let flags: UInt8 + let sequence: UInt32 + let packet: Data + let hasValidCRC: Bool +} + +private func parseOggPages(_ data: Data) throws -> [ParsedOggPage] { + var pages: [ParsedOggPage] = [] + var offset = 0 + while offset < data.count { + guard offset + 27 <= data.count, + data[offset ..< offset + 4] == Data("OggS".utf8) + else { + throw CocoaError(.fileReadCorruptFile) + } + let segmentCount = Int(data[offset + 26]) + guard offset + 27 + segmentCount <= data.count else { + throw CocoaError(.fileReadCorruptFile) + } + let bodyLength = data[ + offset + 27 ..< offset + 27 + segmentCount + ].reduce(0) { $0 + Int($1) } + let end = offset + 27 + segmentCount + bodyLength + guard end <= data.count else { + throw CocoaError(.fileReadCorruptFile) + } + + var page = Data(data[offset ..< end]) + let expectedCRC = page.readLittleEndianUInt32(at: 22) + page.replaceSubrange(22 ..< 26, with: repeatElement(UInt8(0), count: 4)) + pages.append(ParsedOggPage( + flags: data[offset + 5], + sequence: data.readLittleEndianUInt32(at: offset + 18), + packet: Data(data[offset + 27 + segmentCount ..< end]), + hasValidCRC: page.oggTestCRC == expectedCRC + )) + offset = end + } + return pages +} + +private extension Data { + func readLittleEndianUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } + + var oggTestCRC: UInt32 { + var crc: UInt32 = 0 + for byte in self { + crc ^= UInt32(byte) << 24 + for _ in 0 ..< 8 { + crc = (crc & 0x8000_0000) != 0 + ? (crc << 1) ^ 0x04c1_1db7 + : crc << 1 + } + } + return crc + } +} + private func makeTemporaryDirectory() -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("WispAudioKitTests-\(UUID().uuidString)", isDirectory: true) From f5d159930baa0e57037e247487417a7303cec01b Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Mon, 27 Jul 2026 15:47:06 +0900 Subject: [PATCH 2/3] fixup! feat(audio): save recordings as Ogg Opus --- .github/workflows/nix.yaml | 2 +- .github/workflows/swift.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yaml b/.github/workflows/nix.yaml index 0c0689e..06af5f2 100644 --- a/.github/workflows/nix.yaml +++ b/.github/workflows/nix.yaml @@ -13,7 +13,7 @@ permissions: id-token: write jobs: check: - runs-on: ubuntu-slim + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-nix diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index c81e544..9b54169 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -16,7 +16,7 @@ permissions: id-token: write jobs: check: - runs-on: ubuntu-slim + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-nix From 5253e8812aeefd5f9e924bffea0649c97dc913b8 Mon Sep 17 00:00:00 2001 From: pranc1ngpegasus Date: Mon, 27 Jul 2026 15:50:11 +0900 Subject: [PATCH 3/3] fix(ci): format Swift audio changes --- .../WispAudioKit/OpusOggRecorder.swift | 34 +++++++++---------- .../WispAudioKitTests/WispSessionTests.swift | 14 ++++---- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift b/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift index b63af96..7e55dc4 100644 --- a/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift +++ b/native/WispAudioKit/Sources/WispAudioKit/OpusOggRecorder.swift @@ -1,5 +1,5 @@ -@preconcurrency import AVFoundation import AudioToolbox +@preconcurrency import AVFoundation import Foundation import os.lock @@ -69,8 +69,8 @@ final class OpusOggRecorder: @unchecked Sendable { } private final class OpusEncoder: @unchecked Sendable { - private static let outputSampleRate = 48_000.0 - private static let bitRatePerChannel = 32_000 + private static let outputSampleRate = 48000.0 + private static let bitRatePerChannel = 32000 private let channelCount: AVAudioChannelCount private let outputFormat: AVAudioFormat @@ -239,28 +239,26 @@ private enum OpusPacket { static func sampleCount(_ packet: Data) -> UInt64 { guard let toc = packet.first else { return 0 } let config = toc >> 3 - let samplesPerFrame: UInt64 - if config >= 16 { - samplesPerFrame = 120 << UInt64(config & 0x03) + let samplesPerFrame: UInt64 = if config >= 16 { + 120 << UInt64(config & 0x03) } else if config >= 12 { - samplesPerFrame = 480 << UInt64(config & 0x01) + 480 << UInt64(config & 0x01) } else if config & 0x03 == 0x03 { - samplesPerFrame = 2_880 + 2880 } else { - samplesPerFrame = 480 << UInt64(config & 0x03) + 480 << UInt64(config & 0x03) } let frameCode = toc & 0x03 - let frameCount: UInt64 - switch frameCode { + let frameCount: UInt64 = switch frameCode { case 0: - frameCount = 1 + 1 case 1, 2: - frameCount = 2 + 2 default: - frameCount = packet.count > 1 ? UInt64(packet[packet.startIndex + 1] & 0x3f) : 0 + packet.count > 1 ? UInt64(packet[packet.startIndex + 1] & 0x3F) : 0 } - return min(samplesPerFrame * frameCount, 5_760) + return min(samplesPerFrame * frameCount, 5760) } } @@ -296,7 +294,7 @@ private final class OggOpusWriter { head.append(1) // version head.append(channelCount) head.appendLittleEndian(preSkip) - head.appendLittleEndian(UInt32(48_000)) + head.appendLittleEndian(UInt32(48000)) head.appendLittleEndian(Int16(0)) // output gain head.append(0) // channel mapping family 0 (mono/stereo) try writePage(packet: head, granule: 0, flags: 0x02) // BOS @@ -449,7 +447,7 @@ private extension AVAudioPCMBuffer { } private extension Data { - mutating func appendLittleEndian(_ value: T) { + mutating func appendLittleEndian(_ value: some FixedWidthInteger) { append(contentsOf: value.littleEndianBytes) } @@ -459,7 +457,7 @@ private extension Data { crc ^= UInt32(byte) << 24 for _ in 0 ..< 8 { crc = (crc & 0x8000_0000) != 0 - ? (crc << 1) ^ 0x04c1_1db7 + ? (crc << 1) ^ 0x04C1_1DB7 : crc << 1 } } diff --git a/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift b/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift index d480e58..05291b8 100644 --- a/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift +++ b/native/WispAudioKit/Tests/WispAudioKitTests/WispSessionTests.swift @@ -1,5 +1,5 @@ -import Foundation import AVFoundation +import Foundation @testable import WispAudioKit import XCTest @@ -54,16 +54,16 @@ final class WispSessionTests: XCTestCase { let outputDir = makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: outputDir) } let output = outputDir.appendingPathComponent("test.ogg") - let format = AVAudioFormat( + let format = try XCTUnwrap(AVAudioFormat( commonFormat: .pcmFormatFloat32, - sampleRate: 48_000, + sampleRate: 48000, channels: 1, interleaved: false - )! + )) let recorder = try OpusOggRecorder(url: output, sourceFormat: format) for _ in 0 ..< 4 { - let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4_800)! - buffer.frameLength = 4_800 + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4800)) + buffer.frameLength = 4800 recorder.push(buffer) } await recorder.finish() @@ -185,7 +185,7 @@ private extension Data { crc ^= UInt32(byte) << 24 for _ in 0 ..< 8 { crc = (crc & 0x8000_0000) != 0 - ? (crc << 1) ^ 0x04c1_1db7 + ? (crc << 1) ^ 0x04C1_1DB7 : crc << 1 } }