You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
LeCountdown/LeCountdown/Sound/Sound.swift

273 lines
10 KiB

//
// Sound.swift
// LeCountdown
//
// Created by Laurent Morvillier on 30/01/2023.
//
import Foundation
import AVFoundation
protocol Playable: StringRepresentable, Equatable, Hashable {
var soundList: Set<Sound> { get }
}
extension Playlist: Playable {
var stringValue: String { self.rawValue }
var soundList: Set<Sound> {
return Set(SoundCatalog.main.sounds(for: self))
}
}
extension Sound: Playable {
var stringValue: String { self.rawValue.formatted() }
var soundList: Set<Sound> {
return [self]
}
}
protocol Localized {
var localizedString: String { get }
}
class SoundCatalog {
static let main: SoundCatalog = SoundCatalog()
fileprivate var _soundsByPlaylist: [Playlist : [Sound]] = [:]
init() {
self._soundsByPlaylist = Dictionary(grouping: Sound.allCases) { $0.playlist }
}
func sounds(for playlist: Playlist) -> [Sound] {
switch playlist {
case .shorts:
return [.FF_SH_bowl_drone_tap_hold_E, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab, .ESM_Ambient_Game_Menu_Soft_Wood]
default:
return self._soundsByPlaylist[playlist] ?? []
}
}
}
enum Catalog {
case ring
case confirmation
var playlists: [Playlist] {
switch self {
case .ring: return [.stephanBodzin, .nature, .relax]
case .confirmation: return [.shorts]
}
}
}
enum Playlist: String, CaseIterable, Identifiable, Localized {
var id: String { return self.rawValue }
case custom
case nature
case stephanBodzin
case relax
case shorts
var localizedString: String {
switch self {
case .nature:
return NSLocalizedString("Nature", comment: "")
case .stephanBodzin:
return "Stephan Bodzin - Boavista"
case .custom:
return NSLocalizedString("Custom", comment: "")
case .relax:
return NSLocalizedString("Relax", comment: "")
case .shorts:
return NSLocalizedString("Confirmation", comment: "")
}
}
var shortName: String {
switch self {
case .stephanBodzin:
return "Boavista"
default:
return self.localizedString
}
}
}
// Sound id are stored thus case order should not be changed
enum Sound: Int, CaseIterable, Identifiable, Localized {
var id: Int { return self.rawValue }
// StephanBodzin
case sbSEM_Synths_Loop4_Nothing_Like_You
case sbLoop_ToneSD_Boavista
case sbArpeggio_Loop_River
case sbSquareArp_Loop_River
case sbHighChords_Loop_River
case sbMatriarchFxs_Loop2_Collider
// Relax
case FF_SH_bowl_drone_tapping_C
case FF_SH_bowl_drone_tap_hold_E
case EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab
case EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm
case EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am
case EX_ATSM_Bell_Binaural_Flam_Eb
case EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am
case EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm
// Nature
case rain_soft
case stream1
case stream2
case surf1
case crickets
case tropicalForestMorning
case desertMorning
case wetland
case riparianZone
// Shorts
case ESM_Ambient_Game_Menu_Soft_Wood
case sbRose
case trancosoBowl
case natureOceanShore
static var `default`: Sound { .sbSEM_Synths_Loop4_Nothing_Like_You }
var localizedString: String {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "Nothing Like You"
case .sbLoop_ToneSD_Boavista: return "Boavista"
case .sbArpeggio_Loop_River: return "River 1"
case .sbSquareArp_Loop_River: return "River 2"
case .sbHighChords_Loop_River: return "River 3"
case .sbMatriarchFxs_Loop2_Collider: return "Collider"
case .FF_SH_bowl_drone_tapping_C: return "Bowl 1"
case .FF_SH_bowl_drone_tap_hold_E: return "Bowl 2"
case .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm: return "Koshi Chimes 1"
case .EX_ATSM_Bell_Binaural_Flam_Eb: return "Bell Binaural"
case .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am: return "Sansula"
case .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm: return "Chimey percussion"
case .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am: return "Koshi Chimes 2"
case .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab: return "Bowl 3"
case .rain_soft: return "Rain"
case .stream1: return "Stream 1"
case .stream2: return "Stream 2"
case .surf1: return "Surf 1"
case .crickets: return "Crickets"
case .tropicalForestMorning: return "Forest morning 1"
case .desertMorning: return "Desert morning 2"
case .wetland: return "Wetland"
case .riparianZone: return "Riparian Zone"
case .ESM_Ambient_Game_Menu_Soft_Wood: return "Wood percussion"
case .sbRose: return "Rose"
case .trancosoBowl: return "Bowl 4"
case .natureOceanShore: return "Ocean Shore"
}
}
var fileName: String {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "SEM_Synths_Loop4_Nothing_Like_You.wav"
case .sbLoop_ToneSD_Boavista: return "Loop_ToneSD_Boavista.wav"
case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav"
case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav"
case .sbHighChords_Loop_River: return "HighChords_Loop_River.wav"
case .sbMatriarchFxs_Loop2_Collider: return "MatriarchFxs_Loop2_Collider.wav"
case .FF_SH_bowl_drone_tapping_C: return "FF_SH_bowl_drone_tapping_C.wav"
case .FF_SH_bowl_drone_tap_hold_E: return "FF_SH_bowl_drone_tap_hold_E.wav"
case .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm: return "EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm.wav"
case .EX_ATSM_Bell_Binaural_Flam_Eb: return "EX_ATSM_Bell_Binaural_Flam_Eb.wav"
case .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am: return "EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am.wav"
case .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm: return "EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm.wav"
case .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am: return "EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am.wav"
case .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab: return "EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab.wav"
case .rain_soft: return "QP01 0011 Rain soft.wav"
case .stream1: return "QP01 0017 Stream sparkling.wav"
case .stream2: return "QP01 0018 Stream moderate.wav"
case .surf1: return "QP01 0023 Surf moderate sandy.wav"
case .crickets: return "QP01 0028 Insect crickets isolated.wav"
case .tropicalForestMorning: return "QP01 0037 Tropical forest morning.wav"
case .desertMorning: return "QP01 0130 Desert morning bird chorus.wav"
case .wetland: return "QP01 0096 Wetland lake early morning.wav"
case .riparianZone: return "QP01 0118 Riparian Zone thrush.wav"
case .ESM_Ambient_Game_Menu_Soft_Wood: return "ESM_Ambient_Game_Menu_Soft_Wood_Confirm_1_Notification_Button_Settings_UI.wav"
case .sbRose: return "rose1.mp3"
case .trancosoBowl: return "trancoso_bowl1.mp3"
case .natureOceanShore: return "QP01 0075 Ocean shore waves delicate birds.wav"
}
}
var playlist: Playlist {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider, .sbRose:
return .stephanBodzin
case .FF_SH_bowl_drone_tapping_C, .FF_SH_bowl_drone_tap_hold_E, .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm, .EX_ATSM_Bell_Binaural_Flam_Eb, .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am, .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm, .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab, .trancosoBowl:
return .relax
case .rain_soft, .stream1, .stream2, .surf1, .crickets, .tropicalForestMorning, .desertMorning, .wetland, .riparianZone, .natureOceanShore:
return .nature
case .ESM_Ambient_Game_Menu_Soft_Wood:
return .shorts
}
}
var isRestricted: Bool {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_Bell_Binaural_Flam_Eb, .tropicalForestMorning, .rain_soft, .ESM_Ambient_Game_Menu_Soft_Wood:
return false
default:
return true
}
}
var url: URL? {
let components = self.fileName.components(separatedBy: ".")
if components.count == 2 {
return Bundle.main.url(forResource: components[0],
withExtension: components[1])
} else {
print("bad sound file name for \(self)")
return nil
}
}
func soundFile() throws -> SoundFile {
return try SoundFile(fullName: self.fileName)
}
func duration() async throws -> TimeInterval {
guard let url = self.url else {
print("sound \(self) has no url")
return -1.0
}
let audioAsset = AVURLAsset.init(url: url, options: nil)
let duration = try await audioAsset.load(.duration)
return CMTimeGetSeconds(duration)
}
static func computeSoundDurationsIfNecessary() {
Task {
for sound in Sound.allCases {
if Preferences.soundDurations[sound.fileName] == nil {
if let duration = try? await sound.duration() {
Preferences.soundDurations[sound.fileName] = duration
}
}
}
}
}
var formattedDuration: String {
if let duration = Preferences.soundDurations[self.fileName] {
return duration.minuteSecond
} else {
return ""
}
}
}