An offline macOS music player
1,664
stars
556
commits
Swift
primary language
Sep 11, 2026
updated
💡 Tip: Petrichor relies heavily on tracks having good metadata for all its features to work well.
Petrichor imports and plays the 46 file extensions below. The equivalent in-app list is generated from the playback engine itself, so it always reflects the build you are running.
| Format | Extensions | Type |
|---|---|---|
| MP3 / MPEG Audio | .mp3 .mp1 .mp2 .mpa .m1a .m2a .mpeg | Lossy |
| AAC | .aac .m4a .m4r .adts .latm .loas .xhe | Lossy |
| ALAC (Apple Lossless) | .alac .m4a | Lossless |
| FLAC | .flac | Lossless |
| Ogg Vorbis | .ogg .oga .ogx | Lossy |
| Opus | .opus | Lossy |
| Speex | .spx | Lossy |
| WAV | .wav | Lossless |
| Wave64 | .w64 | Lossless |
| AIFF | .aiff .aif | Lossless |
| APE (Monkey's Audio) | .ape | Lossless |
| WavPack | .wv | Lossless |
| TTA (True Audio) | .tta | Lossless |
| TAK | .tak | Lossless |
| Shorten | .shn | Lossless |
| Musepack | .mpc | Lossy |
| DSD | .dsf .dff | Lossless |
| WMA | .wma | Lossy † |
| AC-3 | .ac3 | Lossy |
| E-AC-3 | .eac3 .ec3 | Lossy |
| DTS | .dts | Lossy |
| AMR / AMR-WB | .amr .awb | Lossy |
| CAF (Core Audio Format) | .caf .caff | Container1 |
| Matroska Audio | .mka | Container1 |
| AU / NeXT | .au .snd | Lossless |
| Sound Designer II | .sd2 | Lossless |
1 Containers hold whichever codec was used, and WMA exists in both lossy and lossless variants. Petrichor reads the actual encoding from the file rather than guessing from the extension, so the Lossless badge on a track is always accurate.
Not supported: DRM-protected files (.m4p, .aa, .aax), audiobook containers (.m4b), tracker modules (.mod, .it, .s3m, .xm), RealAudio (.ra, .ram), and MIDI (.mid, .midi).
Starting 1.7, Petrichor no longer supports or imports the tracker-module formats MOD, IT, S3M, and XM that were supported in 1.6.x.
.dmg.brew install --cask petrichor
Refer to official website
I have a large collection of music files that I’ve gathered over the years, and I missed having a good offline music player on macOS. I've used several free and paid options but I missed the simplicity and features commonly found in streaming apps; so I built Petrichor to scratch that itch and learn Swift and macOS app development along the way!
erDiagram
folders {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL"
TEXT path "NOT NULL UNIQUE"
INTEGER track_count "NOT NULL DEFAULT 0"
DATETIME date_added "NOT NULL"
DATETIME date_updated "NOT NULL"
BLOB bookmark_data "Security-scoped bookmark"
TEXT shasum_hash "Folder content hash"
}
artists {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL"
TEXT normalized_name "NOT NULL UNIQUE"
TEXT sort_name
BLOB artwork_data
TEXT bio
TEXT bio_source
DATETIME bio_updated_at
TEXT image_url
TEXT image_source
DATETIME image_updated_at
TEXT discogs_id
TEXT musicbrainz_id
TEXT spotify_id
TEXT apple_music_id
TEXT country
INTEGER formed_year
INTEGER disbanded_year
TEXT genres "JSON array"
TEXT websites "JSON array"
TEXT members "JSON array"
INTEGER total_tracks "NOT NULL DEFAULT 0 CHECK >= 0"
INTEGER total_albums "NOT NULL DEFAULT 0 CHECK >= 0"
DATETIME created_at "NOT NULL"
DATETIME updated_at "NOT NULL"
}
albums {
INTEGER id PK "AUTO_INCREMENT"
TEXT title "NOT NULL"
TEXT normalized_title "NOT NULL"
TEXT sort_title
BLOB artwork_data
TEXT release_date
INTEGER release_year "CHECK 1900-2100"
TEXT album_type
INTEGER total_tracks "CHECK >= 0"
INTEGER total_discs "CHECK >= 0"
TEXT description
TEXT review
TEXT review_source
TEXT cover_art_url
TEXT thumbnail_url
TEXT discogs_id
TEXT musicbrainz_id
TEXT spotify_id
TEXT apple_music_id
TEXT label
TEXT catalog_number
TEXT barcode
TEXT genres "JSON array"
DATETIME created_at "NOT NULL"
DATETIME updated_at "NOT NULL"
}
album_artists {
INTEGER album_id FK "NOT NULL"
INTEGER artist_id FK "NOT NULL"
TEXT role "NOT NULL DEFAULT 'primary'"
INTEGER position "NOT NULL DEFAULT 0"
}
genres {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL UNIQUE"
}
tracks {
INTEGER id PK "AUTO_INCREMENT"
INTEGER folder_id FK "NOT NULL"
INTEGER album_id FK
TEXT path "NOT NULL UNIQUE"
TEXT filename "NOT NULL"
TEXT title
TEXT artist
TEXT album
TEXT composer
TEXT genre
TEXT year
REAL duration "CHECK >= 0"
TEXT format
INTEGER file_size
DATETIME date_added "NOT NULL"
DATETIME date_modified
BLOB track_artwork_data
BOOLEAN is_favorite "NOT NULL DEFAULT false"
INTEGER play_count "NOT NULL DEFAULT 0"
DATETIME last_played_date
BOOLEAN is_duplicate "NOT NULL DEFAULT false"
INTEGER primary_track_id FK
TEXT duplicate_group_id
TEXT album_artist
INTEGER track_number
INTEGER total_tracks
INTEGER disc_number
INTEGER total_discs
INTEGER rating "CHECK 0-5"
BOOLEAN compilation "DEFAULT false"
TEXT release_date
TEXT original_release_date
INTEGER bpm
TEXT media_type "Music/Audiobook/Podcast"
INTEGER bitrate "CHECK > 0"
INTEGER sample_rate
INTEGER channels "1=mono, 2=stereo"
TEXT codec
INTEGER bit_depth
TEXT sort_title
TEXT sort_artist
TEXT sort_album
TEXT sort_album_artist
TEXT extended_metadata "JSON"
BOOLEAN lossless
}
playlists {
TEXT id PK "UUID"
TEXT name "NOT NULL"
TEXT type "NOT NULL (regular/smart)"
BOOLEAN is_user_editable "NOT NULL"
BOOLEAN is_content_editable "NOT NULL"
DATETIME date_created "NOT NULL"
DATETIME date_modified "NOT NULL"
BLOB cover_artwork_data
TEXT smart_criteria "JSON"
INTEGER sort_order "NOT NULL DEFAULT 0"
}
playlist_tracks {
TEXT playlist_id FK "NOT NULL"
INTEGER track_id FK "NOT NULL"
INTEGER position "NOT NULL"
DATETIME date_added "NOT NULL"
}
track_artists {
INTEGER track_id FK "NOT NULL"
INTEGER artist_id FK "NOT NULL"
TEXT role "NOT NULL DEFAULT 'artist'"
INTEGER position "NOT NULL DEFAULT 0"
}
track_genres {
INTEGER track_id FK "NOT NULL"
INTEGER genre_id FK "NOT NULL"
}
pinned_items {
INTEGER id PK "AUTO_INCREMENT"
TEXT item_type "NOT NULL (library/playlist)"
TEXT filter_type "For library items"
TEXT filter_value "Artist/album name"
TEXT entity_id "UUID for entities"
INTEGER artist_id "Database ID"
INTEGER album_id "Database ID"
TEXT playlist_id "For playlist items"
TEXT display_name "NOT NULL"
TEXT subtitle "For albums"
INTEGER sort_order "NOT NULL DEFAULT 0"
DATETIME date_added "NOT NULL"
}
artist_aliases {
TEXT normalized_alias PK
TEXT display_name "NOT NULL"
INTEGER canonical_artist_id FK "NOT NULL"
DATETIME created_at "NOT NULL"
}
album_aliases {
TEXT normalized_key PK
TEXT display_title "NOT NULL"
INTEGER canonical_album_id FK "NOT NULL"
DATETIME created_at "NOT NULL"
}
background_migrations {
TEXT identifier PK
DATETIME completed_at
TEXT progress
BOOLEAN resumable "DEFAULT true"
}
tracks_fts {
INTEGER track_id "NOT INDEXED"
TEXT title
TEXT artist
TEXT album
TEXT album_artist
TEXT composer
TEXT genre
TEXT year
}
folders ||--o{ tracks : contains
albums ||--o{ album_artists : "has artists"
artists ||--o{ album_artists : "appears on"
albums ||--o{ tracks : contains
artists ||--o{ track_artists : "appears in"
tracks ||--o{ track_artists : "has artists"
tracks ||--o{ tracks : "duplicate of"
genres ||--o{ track_genres : "categorizes"
tracks ||--o{ track_genres : "has genres"
playlists ||--o{ playlist_tracks : contains
tracks ||--o{ playlist_tracks : "appears in"
tracks ||--|| tracks_fts : "searchable in"
artists ||--o{ artist_aliases : "merged into"
albums ||--o{ album_aliases : "merged into"
Petrichor wouldn't be possible without following open source projects!
Petrichor.xcodeprojYou can build your own .dmg installer using the build-installer.sh script,
although it requires you to have a paid Apple Developer account to notarize the compiled binary and installer,
you can use --bypass-notary option if you don't want to notarize. To use the script, make sure you have
following tools installed along with Xcode;
Thank you to all the sponsors for supporting Petrichor's development!
For complete third-party license information, see ACKNOWLEDGEMENTS.md
Swift
98.9%
Shell
1.1%
An offline macOS music player
1,664
stars
556
commits
Swift
primary language
Sep 11, 2026
updated
💡 Tip: Petrichor relies heavily on tracks having good metadata for all its features to work well.
Petrichor imports and plays the 46 file extensions below. The equivalent in-app list is generated from the playback engine itself, so it always reflects the build you are running.
| Format | Extensions | Type |
|---|---|---|
| MP3 / MPEG Audio | .mp3 .mp1 .mp2 .mpa .m1a .m2a .mpeg | Lossy |
| AAC | .aac .m4a .m4r .adts .latm .loas .xhe | Lossy |
| ALAC (Apple Lossless) | .alac .m4a | Lossless |
| FLAC | .flac | Lossless |
| Ogg Vorbis | .ogg .oga .ogx | Lossy |
| Opus | .opus | Lossy |
| Speex | .spx | Lossy |
| WAV | .wav | Lossless |
| Wave64 | .w64 | Lossless |
| AIFF | .aiff .aif | Lossless |
| APE (Monkey's Audio) | .ape | Lossless |
| WavPack | .wv | Lossless |
| TTA (True Audio) | .tta | Lossless |
| TAK | .tak | Lossless |
| Shorten | .shn | Lossless |
| Musepack | .mpc | Lossy |
| DSD | .dsf .dff | Lossless |
| WMA | .wma | Lossy † |
| AC-3 | .ac3 | Lossy |
| E-AC-3 | .eac3 .ec3 | Lossy |
| DTS | .dts | Lossy |
| AMR / AMR-WB | .amr .awb | Lossy |
| CAF (Core Audio Format) | .caf .caff | Container1 |
| Matroska Audio | .mka | Container1 |
| AU / NeXT | .au .snd | Lossless |
| Sound Designer II | .sd2 | Lossless |
1 Containers hold whichever codec was used, and WMA exists in both lossy and lossless variants. Petrichor reads the actual encoding from the file rather than guessing from the extension, so the Lossless badge on a track is always accurate.
Not supported: DRM-protected files (.m4p, .aa, .aax), audiobook containers (.m4b), tracker modules (.mod, .it, .s3m, .xm), RealAudio (.ra, .ram), and MIDI (.mid, .midi).
Starting 1.7, Petrichor no longer supports or imports the tracker-module formats MOD, IT, S3M, and XM that were supported in 1.6.x.
.dmg.brew install --cask petrichor
Refer to official website
I have a large collection of music files that I’ve gathered over the years, and I missed having a good offline music player on macOS. I've used several free and paid options but I missed the simplicity and features commonly found in streaming apps; so I built Petrichor to scratch that itch and learn Swift and macOS app development along the way!
erDiagram
folders {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL"
TEXT path "NOT NULL UNIQUE"
INTEGER track_count "NOT NULL DEFAULT 0"
DATETIME date_added "NOT NULL"
DATETIME date_updated "NOT NULL"
BLOB bookmark_data "Security-scoped bookmark"
TEXT shasum_hash "Folder content hash"
}
artists {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL"
TEXT normalized_name "NOT NULL UNIQUE"
TEXT sort_name
BLOB artwork_data
TEXT bio
TEXT bio_source
DATETIME bio_updated_at
TEXT image_url
TEXT image_source
DATETIME image_updated_at
TEXT discogs_id
TEXT musicbrainz_id
TEXT spotify_id
TEXT apple_music_id
TEXT country
INTEGER formed_year
INTEGER disbanded_year
TEXT genres "JSON array"
TEXT websites "JSON array"
TEXT members "JSON array"
INTEGER total_tracks "NOT NULL DEFAULT 0 CHECK >= 0"
INTEGER total_albums "NOT NULL DEFAULT 0 CHECK >= 0"
DATETIME created_at "NOT NULL"
DATETIME updated_at "NOT NULL"
}
albums {
INTEGER id PK "AUTO_INCREMENT"
TEXT title "NOT NULL"
TEXT normalized_title "NOT NULL"
TEXT sort_title
BLOB artwork_data
TEXT release_date
INTEGER release_year "CHECK 1900-2100"
TEXT album_type
INTEGER total_tracks "CHECK >= 0"
INTEGER total_discs "CHECK >= 0"
TEXT description
TEXT review
TEXT review_source
TEXT cover_art_url
TEXT thumbnail_url
TEXT discogs_id
TEXT musicbrainz_id
TEXT spotify_id
TEXT apple_music_id
TEXT label
TEXT catalog_number
TEXT barcode
TEXT genres "JSON array"
DATETIME created_at "NOT NULL"
DATETIME updated_at "NOT NULL"
}
album_artists {
INTEGER album_id FK "NOT NULL"
INTEGER artist_id FK "NOT NULL"
TEXT role "NOT NULL DEFAULT 'primary'"
INTEGER position "NOT NULL DEFAULT 0"
}
genres {
INTEGER id PK "AUTO_INCREMENT"
TEXT name "NOT NULL UNIQUE"
}
tracks {
INTEGER id PK "AUTO_INCREMENT"
INTEGER folder_id FK "NOT NULL"
INTEGER album_id FK
TEXT path "NOT NULL UNIQUE"
TEXT filename "NOT NULL"
TEXT title
TEXT artist
TEXT album
TEXT composer
TEXT genre
TEXT year
REAL duration "CHECK >= 0"
TEXT format
INTEGER file_size
DATETIME date_added "NOT NULL"
DATETIME date_modified
BLOB track_artwork_data
BOOLEAN is_favorite "NOT NULL DEFAULT false"
INTEGER play_count "NOT NULL DEFAULT 0"
DATETIME last_played_date
BOOLEAN is_duplicate "NOT NULL DEFAULT false"
INTEGER primary_track_id FK
TEXT duplicate_group_id
TEXT album_artist
INTEGER track_number
INTEGER total_tracks
INTEGER disc_number
INTEGER total_discs
INTEGER rating "CHECK 0-5"
BOOLEAN compilation "DEFAULT false"
TEXT release_date
TEXT original_release_date
INTEGER bpm
TEXT media_type "Music/Audiobook/Podcast"
INTEGER bitrate "CHECK > 0"
INTEGER sample_rate
INTEGER channels "1=mono, 2=stereo"
TEXT codec
INTEGER bit_depth
TEXT sort_title
TEXT sort_artist
TEXT sort_album
TEXT sort_album_artist
TEXT extended_metadata "JSON"
BOOLEAN lossless
}
playlists {
TEXT id PK "UUID"
TEXT name "NOT NULL"
TEXT type "NOT NULL (regular/smart)"
BOOLEAN is_user_editable "NOT NULL"
BOOLEAN is_content_editable "NOT NULL"
DATETIME date_created "NOT NULL"
DATETIME date_modified "NOT NULL"
BLOB cover_artwork_data
TEXT smart_criteria "JSON"
INTEGER sort_order "NOT NULL DEFAULT 0"
}
playlist_tracks {
TEXT playlist_id FK "NOT NULL"
INTEGER track_id FK "NOT NULL"
INTEGER position "NOT NULL"
DATETIME date_added "NOT NULL"
}
track_artists {
INTEGER track_id FK "NOT NULL"
INTEGER artist_id FK "NOT NULL"
TEXT role "NOT NULL DEFAULT 'artist'"
INTEGER position "NOT NULL DEFAULT 0"
}
track_genres {
INTEGER track_id FK "NOT NULL"
INTEGER genre_id FK "NOT NULL"
}
pinned_items {
INTEGER id PK "AUTO_INCREMENT"
TEXT item_type "NOT NULL (library/playlist)"
TEXT filter_type "For library items"
TEXT filter_value "Artist/album name"
TEXT entity_id "UUID for entities"
INTEGER artist_id "Database ID"
INTEGER album_id "Database ID"
TEXT playlist_id "For playlist items"
TEXT display_name "NOT NULL"
TEXT subtitle "For albums"
INTEGER sort_order "NOT NULL DEFAULT 0"
DATETIME date_added "NOT NULL"
}
artist_aliases {
TEXT normalized_alias PK
TEXT display_name "NOT NULL"
INTEGER canonical_artist_id FK "NOT NULL"
DATETIME created_at "NOT NULL"
}
album_aliases {
TEXT normalized_key PK
TEXT display_title "NOT NULL"
INTEGER canonical_album_id FK "NOT NULL"
DATETIME created_at "NOT NULL"
}
background_migrations {
TEXT identifier PK
DATETIME completed_at
TEXT progress
BOOLEAN resumable "DEFAULT true"
}
tracks_fts {
INTEGER track_id "NOT INDEXED"
TEXT title
TEXT artist
TEXT album
TEXT album_artist
TEXT composer
TEXT genre
TEXT year
}
folders ||--o{ tracks : contains
albums ||--o{ album_artists : "has artists"
artists ||--o{ album_artists : "appears on"
albums ||--o{ tracks : contains
artists ||--o{ track_artists : "appears in"
tracks ||--o{ track_artists : "has artists"
tracks ||--o{ tracks : "duplicate of"
genres ||--o{ track_genres : "categorizes"
tracks ||--o{ track_genres : "has genres"
playlists ||--o{ playlist_tracks : contains
tracks ||--o{ playlist_tracks : "appears in"
tracks ||--|| tracks_fts : "searchable in"
artists ||--o{ artist_aliases : "merged into"
albums ||--o{ album_aliases : "merged into"
Petrichor wouldn't be possible without following open source projects!
Petrichor.xcodeprojYou can build your own .dmg installer using the build-installer.sh script,
although it requires you to have a paid Apple Developer account to notarize the compiled binary and installer,
you can use --bypass-notary option if you don't want to notarize. To use the script, make sure you have
following tools installed along with Xcode;
Thank you to all the sponsors for supporting Petrichor's development!
For complete third-party license information, see ACKNOWLEDGEMENTS.md
Swift
98.9%
Shell
1.1%