diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift index 77d560e..3f9b121 100644 --- a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift +++ b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift @@ -54,6 +54,10 @@ final class AudioFileStreamProcessor { // Track if we're processing Ogg Vorbis private var isProcessingOggVorbis: Bool = false + /// Set by `processSeek()` for FLAC streams; the first packet received afterwards is used to + /// replace the estimated seek time with the position actually reached. + private var correctSeekTimeFromNextFlacFrame = false + var isFileStreamOpen: Bool { audioFileStream != nil || isProcessingOggVorbis } @@ -210,6 +214,7 @@ final class AudioFileStreamProcessor { } readingEntry.reset() + correctSeekTimeFromNextFlacFrame = readingEntry.audioStreamFormat.mFormatID == kAudioFormatFLAC readingEntry.seek(at: Int(seekByteOffset)) rendererContext.waitingForDataAfterSeekFrameCount.write { $0 = 0 } playerContext.setInternalState(to: .waitingForDataAfterSeek) @@ -496,6 +501,15 @@ final class AudioFileStreamProcessor { // reset discontinuity discontinuous = false + if correctSeekTimeFromNextFlacFrame { + correctSeekTimeFromFlacFrame( + entry: entry, + inInputData: inInputData, + inNumberBytes: inNumberBytes, + inPacketDescriptions: inPacketDescriptions + ) + } + var convertInfo = AudioConvertInfo( done: false, numberOfPackets: inNumberPackets, @@ -658,6 +672,30 @@ final class AudioFileStreamProcessor { } } + /// Replaces the seek time with the start time of the first frame received after a FLAC seek. + /// + /// `processSeek()` jumps to a byte offset proportional to the requested time, but FLAC is variable + /// bitrate: the frame found there can be seconds away from that time, and the error would carry + /// on into `progress` until the end of the track. + private func correctSeekTimeFromFlacFrame( + entry: AudioEntry, + inInputData: UnsafeRawPointer, + inNumberBytes: UInt32, + inPacketDescriptions: UnsafeMutablePointer? + ) { + correctSeekTimeFromNextFlacFrame = false + let packetStart = Int(inPacketDescriptions?.pointee.mStartOffset ?? 0) + guard packetStart < Int(inNumberBytes) else { return } + let packet = UnsafeRawBufferPointer(start: inInputData + packetStart, count: Int(inNumberBytes) - packetStart) + + entry.lock.lock(); defer { entry.lock.unlock() } + let format = entry.audioStreamFormat + guard format.mSampleRate > 0, + let sampleNumber = FlacFrameHeader.firstSampleNumber(in: packet, nominalBlockSize: format.mFramesPerPacket) + else { return } + entry.seekTime = Double(sampleNumber) / format.mSampleRate + } + /// Fills the `AudioBuffer` with data as required /// /// - parameter list: An `UnsafeMutableAudioBufferListPointer` object representing the buffer list be filled with data diff --git a/AudioStreaming/Streaming/Helpers/FlacFrameHeader.swift b/AudioStreaming/Streaming/Helpers/FlacFrameHeader.swift new file mode 100644 index 0000000..326d548 --- /dev/null +++ b/AudioStreaming/Streaming/Helpers/FlacFrameHeader.swift @@ -0,0 +1,110 @@ +// +// FlacFrameHeader.swift +// AudioStreaming +// + +import Foundation + +/// Reads the position of a FLAC frame from its header (RFC 9639, section 9.1). +/// +/// Seeking a FLAC stream lands on a byte offset estimated from the bitrate. FLAC is variable +/// bitrate, so that offset rarely corresponds to the requested time. Every frame header records +/// where the frame starts, which lets the player report the position it actually reached. +enum FlacFrameHeader { + /// Returns the index of the first sample in the frame starting at `bytes`, or `nil` if `bytes` + /// does not start with a valid frame header. + /// + /// - Parameters: + /// - bytes: Data starting at the first byte of a frame. + /// - nominalBlockSize: Samples per frame of a fixed-blocksize stream (0 if unknown). Fixed-blocksize + /// frames store a frame number instead of a sample number, and only the last frame may be + /// shorter than the others, so the larger of this value and the frame's own block size is used. + static func firstSampleNumber(in bytes: UnsafeRawBufferPointer, nominalBlockSize: UInt32) -> UInt64? { + // sync code (14 bits) + reserved bit (must be 0) + blocking strategy bit + guard bytes.count >= 6, bytes[0] == 0xFF, bytes[1] & 0xFE == 0xF8 else { return nil } + let isVariableBlockSize = bytes[1] & 0x01 == 1 + + let blockSizeCode = bytes[2] >> 4 + let sampleRateCode = bytes[2] & 0x0F + // block size 0 is reserved and sample rate 0b1111 is forbidden; rejecting them helps + // discard false sync codes found inside audio data + guard blockSizeCode != 0, sampleRateCode != 0x0F else { return nil } + // channel assignments 0b1011...0b1111 and the trailing reserved bit are invalid + guard bytes[3] >> 4 <= 0x0A, bytes[3] & 0x01 == 0 else { return nil } + + var index = 4 + guard let codedNumber = readCodedNumber(bytes, at: &index, maxLength: isVariableBlockSize ? 7 : 6) else { + return nil + } + + let blockSize: UInt32 + switch blockSizeCode { + case 1: + blockSize = 192 + case 2 ... 5: + blockSize = 576 << (UInt32(blockSizeCode) - 2) + case 6: + guard index < bytes.count else { return nil } + blockSize = UInt32(bytes[index]) + 1 + index += 1 + case 7: + guard index + 1 < bytes.count else { return nil } + blockSize = (UInt32(bytes[index]) << 8 | UInt32(bytes[index + 1])) + 1 + index += 2 + default: // 8...15 + blockSize = 256 << (UInt32(blockSizeCode) - 8) + } + + switch sampleRateCode { + case 12: index += 1 + case 13, 14: index += 2 + default: break + } + + guard index < bytes.count, crc8(bytes, count: index) == bytes[index] else { return nil } + + if isVariableBlockSize { + return codedNumber + } + return codedNumber * UInt64(max(blockSize, nominalBlockSize)) + } + + /// Decodes the UTF-8-like coded frame or sample number. + private static func readCodedNumber(_ bytes: UnsafeRawBufferPointer, at index: inout Int, maxLength: Int) -> UInt64? { + guard index < bytes.count else { return nil } + let first = bytes[index] + let length: Int + switch first { + case 0x00 ... 0x7F: length = 1 + case 0xC0 ... 0xDF: length = 2 + case 0xE0 ... 0xEF: length = 3 + case 0xF0 ... 0xF7: length = 4 + case 0xF8 ... 0xFB: length = 5 + case 0xFC ... 0xFD: length = 6 + case 0xFE: length = 7 + default: return nil + } + guard length <= maxLength, index + length <= bytes.count else { return nil } + + var value = length == 1 ? UInt64(first) : UInt64(first & (0x7F >> length)) + for offset in 1 ..< length { + let byte = bytes[index + offset] + guard byte & 0xC0 == 0x80 else { return nil } + value = value << 6 | UInt64(byte & 0x3F) + } + index += length + return value + } + + /// CRC-8 with polynomial x^8 + x^2 + x + 1, initialised to 0. + private static func crc8(_ bytes: UnsafeRawBufferPointer, count: Int) -> UInt8 { + var crc: UInt8 = 0 + for i in 0 ..< count { + crc ^= bytes[i] + for _ in 0 ..< 8 { + crc = crc & 0x80 != 0 ? (crc << 1) ^ 0x07 : crc << 1 + } + } + return crc + } +} diff --git a/AudioStreamingTests/Streaming/Helpers/FlacFrameHeaderTests.swift b/AudioStreamingTests/Streaming/Helpers/FlacFrameHeaderTests.swift new file mode 100644 index 0000000..ebfe239 --- /dev/null +++ b/AudioStreamingTests/Streaming/Helpers/FlacFrameHeaderTests.swift @@ -0,0 +1,57 @@ +// +// FlacFrameHeaderTests.swift +// AudioStreamingTests +// + +import XCTest +@testable import AudioStreaming + +final class FlacFrameHeaderTests: XCTestCase { + // Frame headers taken from a 44.1 kHz FLAC file encoded with a fixed block size of 4608 samples + private let firstFrame: [UInt8] = [0xFF, 0xF8, 0x59, 0x88, 0x00, 0x8A] + private let frame1000: [UInt8] = [0xFF, 0xF8, 0x59, 0x88, 0xCF, 0xA8, 0xC0] + // last frame (number 2296) is 4032 samples long, stored with an explicit 16-bit block size + private let lastFrame: [UInt8] = [0xFF, 0xF8, 0x79, 0x88, 0xE0, 0xA3, 0xB8, 0x0F, 0xBF, 0xBE] + + func test_FixedBlockSize_Frame_Number_Is_Converted_To_Sample_Number() { + XCTAssertEqual(sampleNumber(firstFrame + [0x00, 0x2C, 0x41], nominalBlockSize: 4608), 0) + XCTAssertEqual(sampleNumber(frame1000, nominalBlockSize: 4608), 4_608_000) + } + + func test_FixedBlockSize_Without_Nominal_Uses_Frame_Block_Size() { + XCTAssertEqual(sampleNumber(frame1000, nominalBlockSize: 0), 4_608_000) + } + + func test_Shorter_Last_Frame_Is_Positioned_With_Nominal_Block_Size() { + XCTAssertEqual(sampleNumber(lastFrame, nominalBlockSize: 4608), 2296 * 4608) + } + + func test_VariableBlockSize_Returns_Sample_Number_Directly() { + // 36-bit sample number (7-byte coded number), 16-bit block size, 16-bit sample rate in Hz + let header: [UInt8] = [0xFF, 0xF9, 0x7D, 0x18, 0xFE, 0xA6, 0x87, 0x99, 0x94, 0x8C, 0xA1, 0x0F, 0x9F, 0xAC, 0x44, 0x33] + XCTAssertEqual(sampleNumber(header, nominalBlockSize: 4608), 40_926_266_145) + + let shortHeader: [UInt8] = [0xFF, 0xF9, 0xC9, 0x18, 0xF4, 0xAD, 0x9A, 0x87, 0xF9] + XCTAssertEqual(sampleNumber(shortHeader, nominalBlockSize: 0), 1_234_567) + } + + func test_Header_With_Wrong_CRC_Is_Rejected() { + var header = frame1000 + header[header.count - 1] ^= 0x01 + XCTAssertNil(sampleNumber(header, nominalBlockSize: 4608)) + + var corruptedNumber = frame1000 + corruptedNumber[5] ^= 0x01 + XCTAssertNil(sampleNumber(corruptedNumber, nominalBlockSize: 4608)) + } + + func test_Data_Not_Starting_With_A_Frame_Header_Is_Rejected() { + XCTAssertNil(sampleNumber(Array(firstFrame.dropFirst()), nominalBlockSize: 4608)) + XCTAssertNil(sampleNumber([0xFF, 0xFA] + firstFrame.dropFirst(2), nominalBlockSize: 4608)) + XCTAssertNil(sampleNumber(Array(lastFrame.dropLast()), nominalBlockSize: 4608)) + } + + private func sampleNumber(_ bytes: [UInt8], nominalBlockSize: UInt32) -> UInt64? { + bytes.withUnsafeBytes { FlacFrameHeader.firstSampleNumber(in: $0, nominalBlockSize: nominalBlockSize) } + } +}