Skip to content

Commit

Permalink
Added PlaybackFormat type (for gapless playback)
Browse files Browse the repository at this point in the history
  • Loading branch information
kartik-venugopal committed Dec 20, 2024
1 parent b10e76d commit 112a872
Show file tree
Hide file tree
Showing 17 changed files with 186 additions and 91 deletions.
4 changes: 4 additions & 0 deletions Aural.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@
3E1572AC2C28C0C2006E9965 /* AudioUnitEditorDialogController+PresetsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1572AB2C28C0C2006E9965 /* AudioUnitEditorDialogController+PresetsMenu.swift */; };
3E1741462801EA0A00772ED1 /* TableCellBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1741452801EA0A00772ED1 /* TableCellBuilder.swift */; };
3E1741482801FDE500772ED1 /* TrackListTableViewController+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1741472801FDE500772ED1 /* TrackListTableViewController+DataSource.swift */; };
3E1D255A2D161E98005EA767 /* PlaybackFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1D25592D161E98005EA767 /* PlaybackFormat.swift */; };
3E1DA0D62BEC2B68004670B3 /* ContextHelpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1DA0D52BEC2B68004670B3 /* ContextHelpButton.swift */; };
3E2000BF267CDFFF008BAB70 /* MediaKeysPreferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3E2000BE267CDFFF008BAB70 /* MediaKeysPreferences.xib */; };
3E2000C1267CE00E008BAB70 /* GesturesPreferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3E2000C0267CE00E008BAB70 /* GesturesPreferences.xib */; };
Expand Down Expand Up @@ -1529,6 +1530,7 @@
3E16BE11280DD01200CE9FE9 /* PlaylistNamesTableViewController+TextFieldDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistNamesTableViewController+TextFieldDelegate.swift"; sourceTree = "<group>"; };
3E1741452801EA0A00772ED1 /* TableCellBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableCellBuilder.swift; sourceTree = "<group>"; };
3E1741472801FDE500772ED1 /* TrackListTableViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrackListTableViewController+DataSource.swift"; sourceTree = "<group>"; };
3E1D25592D161E98005EA767 /* PlaybackFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackFormat.swift; sourceTree = "<group>"; };
3E1DA0D52BEC2B68004670B3 /* ContextHelpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextHelpButton.swift; sourceTree = "<group>"; };
3E2000BE267CDFFF008BAB70 /* MediaKeysPreferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MediaKeysPreferences.xib; sourceTree = "<group>"; };
3E2000C0267CE00E008BAB70 /* GesturesPreferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = GesturesPreferences.xib; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2972,6 +2974,7 @@
3E02175C2C23490E00865AC2 /* PrimaryMetadata.swift */,
3E5701952C6EB16A007B8611 /* ReplayGain.swift */,
3E8145C52C7A3850005BA9B9 /* AlbumReplayGain.swift */,
3E1D25592D161E98005EA767 /* PlaybackFormat.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -6603,6 +6606,7 @@
3EFAA8D12B710C30001A6682 /* ChaptersListViewController+Theming.swift in Sources */,
3EFA0C162C67FB63006FB326 /* UnifiedPlayerWaveformContainerViewController.swift in Sources */,
3E0218532C23490E00865AC2 /* FixedSizeLRUArray.swift in Sources */,
3E1D255A2D161E98005EA767 /* PlaybackFormat.swift in Sources */,
3ED373DB2C70CC8100836511 /* ReplayGainUnit.swift in Sources */,
3EF72D6E2B71AB2B005166BF /* DiscreteCircularSlider+Support.swift in Sources */,
3E0219502C23490E00865AC2 /* CoverArtReader.swift in Sources */,
Expand Down
Binary file not shown.
36 changes: 36 additions & 0 deletions Source/Core/LastFM/LastFM_WSClient+Scrobble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,42 @@ import Foundation

extension LastFM_WSClient {

private static let maxPlaybackTime: Double = 240 // 4 minutes

func scrobbleTrackIfEligible(_ track: Track) {

/*

From: https://www.last.fm/api/scrobbling
----------------------------------------

A track should only be scrobbled when the following conditions have been met:

- The track must be longer than 30 seconds.
- And the track has been played for at least half its duration, or for 4 minutes (whichever occurs earlier.)

*/

guard self.scrobblingEnabled,
track.canBeScrobbledOnLastFM,
let historyLastPlayedItem = historyDelegate.lastPlayedItem,
historyLastPlayedItem.track == track else {

NSLog("Cannot scrobble track '\(track)' on Last.fm because scrobbling eligibility conditions were not met.")
return
}

let now = Date()
let playbackTime = now.timeIntervalSince(historyLastPlayedItem.lastEventTime)

if playbackTime >= min(track.duration / 2, Self.maxPlaybackTime) {

DispatchQueue.global(qos: .background).async {
self.scrobbleTrack(track: track, timestamp: historyLastPlayedItem.lastEventTime.epochTime)
}
}
}

func scrobbleTrack(track: Track, timestamp: Int) {

guard let sessionKey = self.sessionKey else {
Expand Down
2 changes: 2 additions & 0 deletions Source/Core/LastFM/LastFM_WSClientProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ protocol LastFM_WSClientProtocol {

func getSession(forToken token: LastFMToken) throws -> LastFMSession

func scrobbleTrackIfEligible(_ track: Track)

func scrobbleTrack(track: Track, timestamp: Int)

func scrobbleTrack(artist: String, title: String, album: String?, timestamp: Int)
Expand Down
21 changes: 21 additions & 0 deletions Source/Core/Persistence/Model/MetadataPersistentState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ struct MetadataPersistentState: Codable {

struct PrimaryMetadataPersistentState: Codable {

let playbackFormat: PlaybackFormatPersistentState?

let title: String?
let artist: String?
let albumArtist: String?
Expand Down Expand Up @@ -52,6 +54,8 @@ struct PrimaryMetadataPersistentState: Codable {

init(metadata: PrimaryMetadata) {

self.playbackFormat = .init(format: metadata.playbackFormat)

self.title = metadata.title
self.artist = metadata.artist
self.album = metadata.album
Expand Down Expand Up @@ -85,6 +89,23 @@ struct PrimaryMetadataPersistentState: Codable {
}
}

struct PlaybackFormatPersistentState: Codable {

let sampleRate: Double?
let channelCount: AVAudioChannelCount?

let layoutTag: AudioChannelLayoutTag?
let channelBitmapRawValue: UInt32?

init(format: PlaybackFormat) {

self.sampleRate = format.sampleRate
self.channelCount = format.channelCount
self.layoutTag = format.layoutTag
self.channelBitmapRawValue = format.channelBitmapRawValue
}
}

struct ChapterPersistentState: Codable {

let title: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,10 @@ import Foundation

class LastFMScrobbleAction: PlaybackChainAction {

private var lastFMPreferences: LastFMPreferences {preferences.metadataPreferences.lastFM}
private static let maxPlaybackTime: Double = 240 // 4 minutes

func execute(_ context: PlaybackRequestContext, _ chain: PlaybackChain) {

/*

From: https://www.last.fm/api/scrobbling
----------------------------------------

A track should only be scrobbled when the following conditions have been met:

- The track must be longer than 30 seconds.
- And the track has been played for at least half its duration, or for 4 minutes (whichever occurs earlier.)

*/

if lastFMPreferences.enableScrobbling.value,
let stoppedTrack = context.currentTrack,
stoppedTrack.canBeScrobbledOnLastFM,
let historyLastPlayedItem = historyDelegate.lastPlayedItem, historyLastPlayedItem.track == stoppedTrack {

let now = Date()
let playbackTime = now.timeIntervalSince(historyLastPlayedItem.lastEventTime)

if playbackTime >= min(stoppedTrack.duration / 2, Self.maxPlaybackTime) {

DispatchQueue.global(qos: .background).async {
lastFMClient.scrobbleTrack(track: stoppedTrack, timestamp: historyLastPlayedItem.lastEventTime.epochTime)
}
}
if let stoppedTrack = context.currentTrack {
lastFMClient.scrobbleTrackIfEligible(stoppedTrack)
}

chain.proceed(context)
Expand Down
1 change: 1 addition & 0 deletions Source/Core/Playback/Delegates/PlaybackDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class PlaybackDelegate: PlaybackDelegateProtocol {
DispatchQueue.global(qos: .userInteractive).async {
playQueueDelegate.prepareForGaplessPlayback()
}
// doBeginGaplessPlayback()
}

private func gaplessPlaybackAnalysisCompleted(notif: GaplessPlaybackAnalysisNotification) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ extension AVFScheduler {

guard let subsequentTrack = gaplessTracksQueue.dequeue() else {return}

// TODO: Prepare for playback here

if let file = (subsequentTrack.playbackContext as? AVFPlaybackContext)?.audioFile {

self.playerNode.scheduleFile(session: session,
Expand Down
15 changes: 4 additions & 11 deletions Source/Core/TrackIO/AVFoundation/AVFFileReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,16 @@ class AVFFileReader: FileReaderProtocol {
func getPrimaryMetadata(for file: URL) throws -> PrimaryMetadata {

// Construct a metadata map for this file.
let metadataMap = AVFMappedMetadata(file: file)
guard let metadataMap = AVFMappedMetadata(file: file) else {throw NoAudioTracksError(file)}
return try doGetPrimaryMetadata(for: file, fromMap: metadataMap)
}

private func doGetPrimaryMetadata(for file: URL, fromMap metadataMap: AVFMappedMetadata) throws -> PrimaryMetadata {

// Make sure the file has at least one audio track.
guard metadataMap.hasAudioTracks else {throw NoAudioTracksError(file)}

// Make sure track is not DRM protected.
guard !metadataMap.avAsset.hasProtectedContent else {throw DRMProtectionError(file)}

// Make sure track is playable.
// TODO: What does isPlayable actually mean ?
// guard metadataMap.audioTrack.isPlayable else {throw TrackNotPlayableError(file)}

let metadata = PrimaryMetadata()
let metadata = PrimaryMetadata(playbackFormat: .init(audioFormat: metadataMap.audioFormat))

// Obtain the parsers relevant to this track, based on the metadata present.
let parsers = metadataMap.keySpaces.compactMap {parsersMap[$0]}
Expand Down Expand Up @@ -109,7 +102,7 @@ class AVFFileReader: FileReaderProtocol {

func getArt(for file: URL) -> CoverArt? {

let metadataMap = AVFMappedMetadata(file: file)
guard let metadataMap = AVFMappedMetadata(file: file) else {return nil}
let parsers = metadataMap.keySpaces.compactMap {parsersMap[$0]}

return parsers.firstNonNilMappedValue {$0.getArt(metadataMap)}
Expand All @@ -124,7 +117,7 @@ class AVFFileReader: FileReaderProtocol {
func getAudioInfo(for file: URL, loadingAudioInfoFrom playbackContext: PlaybackContextProtocol? = nil) -> AudioInfo {

// Construct a metadata map for this file.
let metadataMap = AVFMappedMetadata(file: file)
guard let metadataMap = AVFMappedMetadata(file: file) else {return .init()}
return doGetAudioInfo(for: file, fromMap: metadataMap, loadingAudioInfoFrom: playbackContext)
}

Expand Down
14 changes: 8 additions & 6 deletions Source/Core/TrackIO/AVFoundation/Utils/AVFMappedMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,12 @@ struct AVFMappedMetadata {
///
let avAsset: AVURLAsset

///
/// Whether or not the represented file contains any audio tracks. Used for track validation.
///
var hasAudioTracks: Bool {avAsset.tracks.first(where: {$0.mediaType == .audio}) != nil}
let audioFormat: AVAudioFormat

///
/// The AVFoundation audio track object that contains track-level information (such as bit rate).
///
var audioTrack: AVAssetTrack {avAsset.tracks.first(where: {$0.mediaType == .audio})!}
let audioTrack: AVAssetTrack

///
/// The following dictionaries contain mappings of key -> AVMetadataItem for each of the supported metadata key spaces.
Expand All @@ -52,11 +49,16 @@ struct AVFMappedMetadata {
///
var keySpaces: [AVMetadataKeySpace] = []

init(file: URL) {
init?(file: URL) {

self.file = file
self.avAsset = AVURLAsset(url: file, options: nil)

guard let audioTrack = avAsset.tracks.first(where: {$0.mediaType == .audio}) else {return nil}

self.audioTrack = audioTrack
self.audioFormat = .init(cmAudioFormatDescription: audioTrack.formatDescription)

// Iterate through all metadata items, and group them based on
// key space.

Expand Down
7 changes: 5 additions & 2 deletions Source/Core/TrackIO/FFmpeg/FFmpegFileReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// This software is licensed under the MIT software license.
// See the file "LICENSE" in the project root directory for license terms.
//
import Foundation
import AVFoundation

///
/// Handles loading of track metadata from non-native tracks, using **FFmpeg*.
Expand Down Expand Up @@ -93,7 +93,10 @@ class FFmpegFileReader: FileReaderProtocol {

private func doGetPrimaryMetadata(for file: URL, fromCtx fctx: FFmpegFileContext, stream: FFmpegAudioStream, codec: FFmpegAudioCodec, andMap metadataMap: FFmpegMappedMetadata, usingParsers relevantParsers: [FFmpegMetadataParser]) throws -> PrimaryMetadata {

let metadata = PrimaryMetadata()
let audioFormat: AVAudioFormat = .init(standardFormatWithSampleRate: Double(codec.sampleRate),
channelLayout: codec.channelLayout.avfLayout)

let metadata = PrimaryMetadata(playbackFormat: .init(audioFormat: audioFormat))

// Read all essential metadata fields.

Expand Down
84 changes: 84 additions & 0 deletions Source/Core/TrackIO/Model/PlaybackFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// PlaybackFormat.swift
// Aural
//
// Copyright © 2024 Kartik Venugopal. All rights reserved.
//
// This software is licensed under the MIT software license.
// See the file "LICENSE" in the project root directory for license terms.
//

import AVFoundation

struct PlaybackFormat {

let sampleRate: Double
let channelCount: AVAudioChannelCount

let layoutTag: AudioChannelLayoutTag?
let channelBitmapRawValue: UInt32?

init(audioFormat: AVAudioFormat) {

self.sampleRate = audioFormat.sampleRate
self.channelCount = audioFormat.channelCount
self.layoutTag = audioFormat.channelLayout?.layoutTag

if self.layoutTag == kAudioChannelLayoutTag_UseChannelBitmap {
self.channelBitmapRawValue = audioFormat.channelLayout?.layout.pointee.mChannelBitmap.rawValue
} else {
self.channelBitmapRawValue = 0
}
}

init?(persistentState: PlaybackFormatPersistentState) {

guard let sampleRate = persistentState.sampleRate,
let channelCount = persistentState.channelCount else {return nil}

self.sampleRate = sampleRate
self.channelCount = channelCount

self.layoutTag = persistentState.layoutTag
self.channelBitmapRawValue = persistentState.channelBitmapRawValue
}
}

extension PlaybackFormat: Hashable {

static func == (lhs: PlaybackFormat, rhs: PlaybackFormat) -> Bool {

if lhs.sampleRate != rhs.sampleRate {
return false
}

if lhs.channelCount != rhs.channelCount {
return false
}

if lhs.channelCount <= 2 {
return true
}

// MARK: Channel count > 2 --------------------------------------------------

if lhs.layoutTag != rhs.layoutTag {
return false
}

if lhs.layoutTag == kAudioChannelLayoutTag_UseChannelBitmap {
return lhs.channelBitmapRawValue == rhs.channelBitmapRawValue

} else {
return true
}
}

func hash(into hasher: inout Hasher) {

hasher.combine(sampleRate)
hasher.combine(channelCount)
hasher.combine(layoutTag)
hasher.combine(channelBitmapRawValue)
}
}
13 changes: 11 additions & 2 deletions Source/Core/TrackIO/Model/PrimaryMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import Foundation
///
class PrimaryMetadata {

let playbackFormat: PlaybackFormat

var title: String?
var artist: String?
var albumArtist: String?
Expand Down Expand Up @@ -72,9 +74,16 @@ class PrimaryMetadata {

var replayGain: ReplayGain?

init() {}
init(playbackFormat: PlaybackFormat) {
self.playbackFormat = playbackFormat
}

init(persistentState: PrimaryMetadataPersistentState, persistentCoverArt: CoverArt?) {
init?(persistentState: PrimaryMetadataPersistentState, persistentCoverArt: CoverArt?) {

guard let playbackFormatState = persistentState.playbackFormat,
let playbackFormat = PlaybackFormat(persistentState: playbackFormatState) else {return nil}

self.playbackFormat = playbackFormat

self.title = persistentState.title
self.artist = persistentState.artist
Expand Down
Loading

0 comments on commit 112a872

Please sign in to comment.