From b8b670642c74ee1c7baedc2acade6045703e697e Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 12 Jun 2026 21:10:37 +0700 Subject: [PATCH] feat(audio): add WAV and AIFF support + settings-style metadata menu WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 4 + go_backend/audio_metadata.go | 3 + go_backend/cue_parser.go | 2 +- go_backend/exports.go | 67 ++ go_backend/library_scan.go | 9 +- go_backend/metadata.go | 26 + go_backend/wav_aiff.go | 975 ++++++++++++++++++ lib/screens/downloaded_album_screen.dart | 37 +- lib/screens/local_album_screen.dart | 34 +- lib/screens/queue_tab.dart | 61 +- lib/screens/track_metadata_screen.dart | 131 +-- lib/services/ffmpeg_service.dart | 249 ++++- lib/services/library_database.dart | 5 + lib/services/replaygain_service.dart | 4 + lib/utils/audio_conversion_utils.dart | 40 +- lib/utils/mime_utils.dart | 4 + lib/utils/path_match_keys.dart | 2 + 17 files changed, 1481 insertions(+), 172 deletions(-) create mode 100644 go_backend/wav_aiff.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index e653cfb8..2e23f238 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -307,6 +307,8 @@ class MainActivity: FlutterFragmentActivity() { ".mp3" -> "audio/mpeg" ".opus" -> "audio/ogg" ".flac" -> "audio/flac" + ".wav" -> "audio/wav" + ".aiff", ".aif", ".aifc" -> "audio/aiff" ".lrc" -> "application/octet-stream" else -> "application/octet-stream" } @@ -791,6 +793,8 @@ class MainActivity: FlutterFragmentActivity() { "audio/mpeg" -> ".mp3" "audio/ogg" -> ".opus" "audio/flac" -> ".flac" + "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav" + "audio/aiff", "audio/x-aiff" -> ".aiff" else -> "" } } diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 52a56d56..ce791dd7 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin } return data, mimeType, nil + case ".wav", ".aiff", ".aif", ".aifc": + return extractWAVAIFFCover(filePath) + default: return nil, "", fmt.Errorf("unsupported format: %s", ext) } diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 4b1b178a..47209f6c 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string { } baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName)) - commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} + commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} for _, ext := range commonExts { candidate = filepath.Join(cueDir, baseName+ext) if _, err := os.Stat(candidate); err == nil { diff --git a/go_backend/exports.go b/go_backend/exports.go index 6fcfe2c5..7eca9dfc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1160,6 +1160,8 @@ func ReadFileMetadata(filePath string) (string, error) { isApe := strings.HasSuffix(lower, ".ape") isWv := strings.HasSuffix(lower, ".wv") isMpc := strings.HasSuffix(lower, ".mpc") + isWav := strings.HasSuffix(lower, ".wav") + isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") result := map[string]interface{}{ "title": "", @@ -1406,6 +1408,51 @@ func ReadFileMetadata(filePath string) (string, error) { result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak } } + } else if isWav || isAiff { + var meta *AudioMetadata + var quality *WAVQuality + var qualityErr error + if isAiff { + result["format"] = "aiff" + result["audio_codec"] = "pcm" + meta, _ = ReadAIFFTags(filePath) + quality, qualityErr = GetAIFFQuality(filePath) + } else { + result["format"] = "wav" + result["audio_codec"] = "pcm" + meta, _ = ReadWAVTags(filePath) + quality, qualityErr = GetWAVQuality(filePath) + } + if meta != nil { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + if qualityErr == nil && quality != nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } } else { return "", fmt.Errorf("unsupported file format: %s", filePath) } @@ -1474,6 +1521,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { isFlac := strings.HasSuffix(lower, ".flac") isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc") isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b") + isWavFile := strings.HasSuffix(lower, ".wav") + isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") coverPath := strings.TrimSpace(fields["cover_path"]) if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) { @@ -1502,6 +1551,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } + // WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively. + if isWavFile { + if err := WriteWAVTags(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write WAV metadata: %w", err) + } + resp := map[string]any{"success": true, "method": "native_wav"} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + if isAiffFile { + if err := WriteAIFFTags(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write AIFF metadata: %w", err) + } + resp := map[string]any{"success": true, "method": "native_aiff"} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + // APE/WV/MPC: write APEv2 tags natively if isApeFile { trackNum := 0 diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 91aaf4b0..65328959 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -76,6 +76,9 @@ var supportedAudioFormats = map[string]bool{ ".ape": true, ".wv": true, ".mpc": true, + ".wav": true, + ".aiff": true, + ".aif": true, ".cue": true, } @@ -340,6 +343,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ return scanOggFile(filePath, result, displayNameHint) case ".ape", ".wv", ".mpc": return scanAPEFile(filePath, result, displayNameHint) + case ".wav": + return scanWAVFile(filePath, result, displayNameHint) + case ".aiff", ".aif", ".aifc": + return scanAIFFFile(filePath, result, displayNameHint) default: return scanFromFilename(filePath, displayNameHint, result) } @@ -479,7 +486,7 @@ func libraryFormatForM4ACodec(codec string) string { func isLosslessLibraryFormat(format string) bool { switch strings.ToLower(strings.TrimSpace(format)) { - case "flac", "alac": + case "flac", "alac", "wav", "aiff", "aif", "aifc": return true default: return false diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 090aa12c..763678a4 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -906,6 +906,32 @@ func ExtractLyrics(filePath string) (string, error) { return extractLyricsFromSidecarLRC(filePath) } + if strings.HasSuffix(lower, ".wav") { + meta, err := ReadWAVTags(filePath) + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } + } + return extractLyricsFromSidecarLRC(filePath) + } + + if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") { + meta, err := ReadAIFFTags(filePath) + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } + } + return extractLyricsFromSidecarLRC(filePath) + } + return extractLyricsFromSidecarLRC(filePath) } diff --git a/go_backend/wav_aiff.go b/go_backend/wav_aiff.go new file mode 100644 index 00000000..7ca1f530 --- /dev/null +++ b/go_backend/wav_aiff.go @@ -0,0 +1,975 @@ +package gobackend + +// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and +// cover-art extraction. These containers are not handled by go-flac, so chunks +// are parsed/written by hand here. +// +// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase +// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because +// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe +// frame sizes and UTF-8 text, so anything we write is read back losslessly. +// +// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files +// that carry only RIFF INFO tags (common from other taggers). + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "os" + "path/filepath" + "strconv" + "strings" +) + +// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes. +type WAVQuality struct { + SampleRate int + BitDepth int + Channels int + Duration int +} + +const ( + wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk + id3ChunkWAV = "id3 " + id3ChunkAIFF = "ID3 " + wavFormatPCM = 0x0001 + wavFormatFloat = 0x0003 + wavFormatExtensn = 0xFFFE +) + +// ---------- low-level chunk size helpers ---------- + +func putUint32(dst []byte, le bool, v uint32) { + if le { + binary.LittleEndian.PutUint32(dst, v) + } else { + binary.BigEndian.PutUint32(dst, v) + } +} + +func readUint32(b []byte, le bool) uint32 { + if le { + return binary.LittleEndian.Uint32(b) + } + return binary.BigEndian.Uint32(b) +} + +func synchsafeEncode(n int) []byte { + return []byte{ + byte((n >> 21) & 0x7f), + byte((n >> 14) & 0x7f), + byte((n >> 7) & 0x7f), + byte(n & 0x7f), + } +} + +func synchsafeDecode(b []byte) int { + if len(b) < 4 { + return 0 + } + return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3]) +} + +// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the +// AIFF COMM chunk for the sample rate). +func parseExtendedFloat80(b []byte) float64 { + if len(b) < 10 { + return 0 + } + sign := 1.0 + if b[0]&0x80 != 0 { + sign = -1.0 + } + exponent := int(b[0]&0x7f)<<8 | int(b[1]) + var mantissa uint64 + for i := 2; i < 10; i++ { + mantissa = mantissa<<8 | uint64(b[i]) + } + if exponent == 0 && mantissa == 0 { + return 0 + } + return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63)) +} + +// ---------- WAV (RIFF) ---------- + +type wavProbe struct { + sampleRate int + bitDepth int + channels int + byteRate int + dataSize int64 + id3 []byte + info map[string]string +} + +// streamProbeWAV walks the top-level RIFF chunks, buffering only the small +// metadata chunks (fmt/id3/LIST) and skipping the large data chunk. +func streamProbeWAV(f *os.File) (*wavProbe, error) { + header := make([]byte, 12) + if _, err := io.ReadFull(f, header); err != nil { + return nil, err + } + if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { + return nil, fmt.Errorf("not a WAVE file") + } + + p := &wavProbe{info: map[string]string{}} + hdr := make([]byte, 8) + for { + if _, err := io.ReadFull(f, hdr); err != nil { + break + } + id := string(hdr[0:4]) + size := readUint32(hdr[4:8], true) + pad := int64(size) & 1 + + switch id { + case "fmt ": + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err != nil { + return p, nil + } + if len(buf) >= 16 { + format := binary.LittleEndian.Uint16(buf[0:2]) + p.channels = int(binary.LittleEndian.Uint16(buf[2:4])) + p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8])) + p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12])) + p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16])) + if format == wavFormatExtensn && len(buf) >= 26 { + // Valid bits per sample lives in the extension; the real + // PCM format tag is in the GUID, but bitDepth from the + // container field is sufficient for display. + if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 { + p.bitDepth = vb + } + } + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + case "data": + p.dataSize = int64(size) + f.Seek(int64(size)+pad, io.SeekCurrent) + case id3ChunkWAV, "ID3 ": + if size > 0 && size <= wavMaxMetaChunk { + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err == nil { + p.id3 = buf + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + } else { + f.Seek(int64(size)+pad, io.SeekCurrent) + } + case "LIST": + if size > 0 && size <= wavMaxMetaChunk { + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err == nil { + parseRIFFInfo(buf, p.info) + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + } else { + f.Seek(int64(size)+pad, io.SeekCurrent) + } + default: + f.Seek(int64(size)+pad, io.SeekCurrent) + } + } + return p, nil +} + +// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART). +func parseRIFFInfo(buf []byte, out map[string]string) { + if len(buf) < 4 || string(buf[0:4]) != "INFO" { + return + } + pos := 4 + for pos+8 <= len(buf) { + id := string(buf[pos : pos+4]) + size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8])) + pos += 8 + if size <= 0 || pos+size > len(buf) { + break + } + val := strings.TrimRight(string(buf[pos:pos+size]), "\x00") + out[id] = strings.TrimSpace(val) + pos += size + if size&1 == 1 { + pos++ + } + } +} + +func wavMetadataFromProbe(p *wavProbe) *AudioMetadata { + if p == nil { + return nil + } + if len(p.id3) > 0 { + if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil && + (meta.Title != "" || meta.Artist != "" || meta.Album != "") { + return meta + } + } + if len(p.info) > 0 { + meta := &AudioMetadata{ + Title: p.info["INAM"], + Artist: p.info["IART"], + Album: p.info["IPRD"], + Genre: cleanGenre(p.info["IGNR"]), + Date: p.info["ICRD"], + Comment: p.info["ICMT"], + Copyright: p.info["ICOP"], + Composer: p.info["IMUS"], + } + if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil { + meta.TrackNumber = n + } + if meta.Date != "" && len(meta.Date) >= 4 { + meta.Year = meta.Date[:4] + } + if meta.Title != "" || meta.Artist != "" || meta.Album != "" { + return meta + } + } + return nil +} + +// GetWAVQuality probes PCM parameters and computes duration from the data size. +func GetWAVQuality(filePath string) (*WAVQuality, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + p, err := streamProbeWAV(f) + if err != nil { + return nil, err + } + q := &WAVQuality{ + SampleRate: p.sampleRate, + BitDepth: p.bitDepth, + Channels: p.channels, + } + if p.byteRate > 0 && p.dataSize > 0 { + q.Duration = int(p.dataSize / int64(p.byteRate)) + } else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 { + bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8) + if bytesPerSec > 0 { + q.Duration = int(p.dataSize / bytesPerSec) + } + } + return q, nil +} + +// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback). +func ReadWAVTags(filePath string) (*AudioMetadata, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + p, err := streamProbeWAV(f) + if err != nil { + return nil, err + } + meta := wavMetadataFromProbe(p) + if meta == nil { + return nil, fmt.Errorf("no WAV tags found") + } + return meta, nil +} + +// ---------- AIFF / AIFC ---------- + +type aiffProbe struct { + sampleRate int + bitDepth int + channels int + numFrames int64 + id3 []byte + nameChunk string + authChunk string + annoChunk string + copyrightChunk string +} + +func streamProbeAIFF(f *os.File) (*aiffProbe, error) { + header := make([]byte, 12) + if _, err := io.ReadFull(f, header); err != nil { + return nil, err + } + form := string(header[8:12]) + if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") { + return nil, fmt.Errorf("not an AIFF file") + } + + p := &aiffProbe{} + hdr := make([]byte, 8) + for { + if _, err := io.ReadFull(f, hdr); err != nil { + break + } + id := string(hdr[0:4]) + size := readUint32(hdr[4:8], false) + pad := int64(size) & 1 + + switch id { + case "COMM": + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err != nil { + return p, nil + } + if len(buf) >= 18 { + p.channels = int(binary.BigEndian.Uint16(buf[0:2])) + p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6])) + p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8])) + p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5) + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + case id3ChunkAIFF, "id3 ": + if size > 0 && size <= wavMaxMetaChunk { + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err == nil { + p.id3 = buf + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + } else { + f.Seek(int64(size)+pad, io.SeekCurrent) + } + case "NAME", "AUTH", "ANNO", "(c) ": + if size > 0 && size <= wavMaxMetaChunk { + buf := make([]byte, size) + if _, err := io.ReadFull(f, buf); err == nil { + val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00") + switch id { + case "NAME": + p.nameChunk = val + case "AUTH": + p.authChunk = val + case "ANNO": + p.annoChunk = val + case "(c) ": + p.copyrightChunk = val + } + } + if pad == 1 { + f.Seek(pad, io.SeekCurrent) + } + } else { + f.Seek(int64(size)+pad, io.SeekCurrent) + } + default: + f.Seek(int64(size)+pad, io.SeekCurrent) + } + } + return p, nil +} + +func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata { + if p == nil { + return nil + } + if len(p.id3) > 0 { + if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil && + (meta.Title != "" || meta.Artist != "" || meta.Album != "") { + return meta + } + } + if p.nameChunk != "" || p.authChunk != "" { + meta := &AudioMetadata{ + Title: p.nameChunk, + Artist: p.authChunk, + Comment: p.annoChunk, + Copyright: p.copyrightChunk, + } + return meta + } + return nil +} + +// GetAIFFQuality probes PCM parameters and computes duration from frame count. +func GetAIFFQuality(filePath string) (*WAVQuality, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + p, err := streamProbeAIFF(f) + if err != nil { + return nil, err + } + q := &WAVQuality{ + SampleRate: p.sampleRate, + BitDepth: p.bitDepth, + Channels: p.channels, + } + if p.sampleRate > 0 && p.numFrames > 0 { + q.Duration = int(p.numFrames / int64(p.sampleRate)) + } + return q, nil +} + +// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback). +func ReadAIFFTags(filePath string) (*AudioMetadata, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + p, err := streamProbeAIFF(f) + if err != nil { + return nil, err + } + meta := aiffMetadataFromProbe(p) + if meta == nil { + return nil, fmt.Errorf("no AIFF tags found") + } + return meta, nil +} + +// ---------- ID3v2 reading from a buffered chunk ---------- + +// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 " +// or AIFF "ID3 " chunk) by reusing the existing frame parsers. +func readID3v2FromBytes(data []byte) (*AudioMetadata, error) { + if len(data) < 10 || string(data[0:3]) != "ID3" { + return nil, fmt.Errorf("no ID3v2 header") + } + majorVersion := data[3] + flags := data[5] + unsync := (flags & 0x80) != 0 + extendedHeader := (flags & 0x40) != 0 + footerPresent := (flags & 0x10) != 0 + + size := synchsafeDecode(data[6:10]) + if size <= 0 || 10+size > len(data) { + size = len(data) - 10 + } + tagData := data[10 : 10+size] + + if footerPresent && len(tagData) >= 10 { + footerStart := len(tagData) - 10 + if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" { + tagData = tagData[:footerStart] + } + } + if extendedHeader { + if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) { + tagData = tagData[skip:] + } + } + + metadata := &AudioMetadata{} + if majorVersion == 2 { + parseID3v22Frames(tagData, metadata, unsync) + } else { + parseID3v23Frames(tagData, metadata, majorVersion, unsync) + } + return metadata, nil +} + +// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME. +func extractAPICFromID3(tag []byte) ([]byte, string) { + if len(tag) < 10 || string(tag[0:3]) != "ID3" { + return nil, "" + } + ver := tag[3] + size := synchsafeDecode(tag[6:10]) + if size <= 0 || 10+size > len(tag) { + size = len(tag) - 10 + } + data := tag[10 : 10+size] + + pos := 0 + for { + if ver == 2 { + if pos+6 > len(data) || data[pos] == 0 { + break + } + id := string(data[pos : pos+3]) + fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5]) + if fsz <= 0 || pos+6+fsz > len(data) { + break + } + if id == "PIC" { + return parseAPICFrame(data[pos+6:pos+6+fsz], ver) + } + pos += 6 + fsz + continue + } + + if pos+10 > len(data) || data[pos] == 0 { + break + } + id := string(data[pos : pos+4]) + var fsz int + if ver == 4 { + fsz = synchsafeDecode(data[pos+4 : pos+8]) + } else { + fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8])) + } + if fsz <= 0 || pos+10+fsz > len(data) { + break + } + if id == "APIC" { + return parseAPICFrame(data[pos+10:pos+10+fsz], ver) + } + pos += 10 + fsz + } + return nil, "" +} + +// ---------- ID3v2.4 building ---------- + +// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover. +func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte { + var frames bytes.Buffer + + writeFrame := func(id string, payload []byte) { + frames.WriteString(id) + frames.Write(synchsafeEncode(len(payload))) + frames.Write([]byte{0, 0}) + frames.Write(payload) + } + writeText := func(id, val string) { + if strings.TrimSpace(val) == "" { + return + } + payload := append([]byte{0x03}, []byte(val)...) + writeFrame(id, payload) + } + + writeText("TIT2", meta.Title) + writeText("TPE1", meta.Artist) + writeText("TALB", meta.Album) + writeText("TPE2", meta.AlbumArtist) + writeText("TCON", meta.Genre) + writeText("TCOM", meta.Composer) + writeText("TPUB", meta.Label) + writeText("TCOP", meta.Copyright) + writeText("TSRC", meta.ISRC) + + date := meta.Date + if date == "" { + date = meta.Year + } + writeText("TDRC", date) + + if meta.TrackNumber > 0 { + if meta.TotalTracks > 0 { + writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks)) + } else { + writeText("TRCK", strconv.Itoa(meta.TrackNumber)) + } + } + if meta.DiscNumber > 0 { + if meta.TotalDiscs > 0 { + writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs)) + } else { + writeText("TPOS", strconv.Itoa(meta.DiscNumber)) + } + } + + if strings.TrimSpace(meta.Comment) != "" { + // COMM: encoding + language(3) + short desc(null) + text + payload := []byte{0x03} + payload = append(payload, []byte("eng")...) + payload = append(payload, 0x00) // empty description + payload = append(payload, []byte(meta.Comment)...) + writeFrame("COMM", payload) + } + if strings.TrimSpace(meta.Lyrics) != "" { + payload := []byte{0x03} + payload = append(payload, []byte("eng")...) + payload = append(payload, 0x00) + payload = append(payload, []byte(meta.Lyrics)...) + writeFrame("USLT", payload) + } + + // ReplayGain as TXXX (description\0value), UTF-8. + writeTXXX := func(desc, val string) { + if strings.TrimSpace(val) == "" { + return + } + payload := []byte{0x03} + payload = append(payload, []byte(desc)...) + payload = append(payload, 0x00) + payload = append(payload, []byte(val)...) + writeFrame("TXXX", payload) + } + writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain) + writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak) + writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain) + writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak) + + if len(coverData) > 0 { + if strings.TrimSpace(coverMIME) == "" { + coverMIME = "image/jpeg" + } + // APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data + payload := []byte{0x03} + payload = append(payload, []byte(coverMIME)...) + payload = append(payload, 0x00) + payload = append(payload, 0x03) + payload = append(payload, 0x00) + payload = append(payload, coverData...) + writeFrame("APIC", payload) + } + + body := frames.Bytes() + var out bytes.Buffer + out.WriteString("ID3") + out.Write([]byte{0x04, 0x00}) // v2.4.0 + out.WriteByte(0x00) // flags + out.Write(synchsafeEncode(len(body))) + out.Write(body) + return out.Bytes() +} + +// ---------- tag writing (streaming chunk rewrite) ---------- + +// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID, +// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end. +// The audio data and all other chunks are preserved; container size is patched. +func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error { + in, err := os.Open(filePath) + if err != nil { + return err + } + defer in.Close() + + header := make([]byte, 12) + if _, err := io.ReadFull(in, header); err != nil { + return err + } + if string(header[0:4]) != expectMagic { + return fmt.Errorf("unexpected container magic %q", string(header[0:4])) + } + + tmpPath := filePath + ".tagtmp" + out, err := os.Create(tmpPath) + if err != nil { + return err + } + cleanup := func() { + out.Close() + os.Remove(tmpPath) + } + + if _, err := out.Write(header); err != nil { + cleanup() + return err + } + + var bodyLen int64 = 4 // the 4-byte form type after the size field + hdr := make([]byte, 8) + for { + n, rerr := io.ReadFull(in, hdr) + if n < 8 { + break + } + if rerr != nil { + break + } + id := string(hdr[0:4]) + size := readUint32(hdr[4:8], le) + pad := int64(size) & 1 + + if strings.EqualFold(id, chunkID) { + // Drop the existing tag chunk. + if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil { + cleanup() + return err + } + continue + } + + if _, err := out.Write(hdr); err != nil { + cleanup() + return err + } + if _, err := io.CopyN(out, in, int64(size)+pad); err != nil { + cleanup() + return err + } + bodyLen += 8 + int64(size) + pad + } + + // Append the new tag chunk. + newSize := len(id3) + chunkHdr := make([]byte, 8) + copy(chunkHdr[0:4], chunkID) + putUint32(chunkHdr[4:8], le, uint32(newSize)) + if _, err := out.Write(chunkHdr); err != nil { + cleanup() + return err + } + if _, err := out.Write(id3); err != nil { + cleanup() + return err + } + if newSize&1 == 1 { + if _, err := out.Write([]byte{0}); err != nil { + cleanup() + return err + } + } + bodyLen += 8 + int64(newSize) + int64(newSize&1) + + // Patch the container size field (bytes 4..8). + sizeBuf := make([]byte, 4) + putUint32(sizeBuf, le, uint32(bodyLen)) + if _, err := out.WriteAt(sizeBuf, 4); err != nil { + cleanup() + return err + } + + if err := out.Close(); err != nil { + os.Remove(tmpPath) + return err + } + in.Close() + + return os.Rename(tmpPath, filePath) +} + +func loadCoverForTag(fields map[string]string) ([]byte, string) { + coverPath := strings.TrimSpace(fields["cover_path"]) + if coverPath == "" { + return nil, "" + } + data, err := os.ReadFile(coverPath) + if err != nil || len(data) == 0 { + return nil, "" + } + mime := "image/jpeg" + if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + mime = "image/png" + } + return data, mime +} + +func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata { + atoi := func(k string) int { + n := 0 + if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" { + fmt.Sscanf(strings.TrimSpace(v), "%d", &n) + } + return n + } + return &AudioMetadata{ + Title: fields["title"], + Artist: fields["artist"], + Album: fields["album"], + AlbumArtist: fields["album_artist"], + Date: fields["date"], + TrackNumber: atoi("track_number"), + TotalTracks: atoi("track_total"), + DiscNumber: atoi("disc_number"), + TotalDiscs: atoi("disc_total"), + ISRC: fields["isrc"], + Lyrics: fields["lyrics"], + Genre: fields["genre"], + Label: fields["label"], + Copyright: fields["copyright"], + Composer: fields["composer"], + Comment: fields["comment"], + ReplayGainTrackGain: fields["replaygain_track_gain"], + ReplayGainTrackPeak: fields["replaygain_track_peak"], + ReplayGainAlbumGain: fields["replaygain_album_gain"], + ReplayGainAlbumPeak: fields["replaygain_album_peak"], + } +} + +// mergeWAVEditFields merges edit fields onto existing tags so untouched fields +// (and cover art, when no new cover is provided) are preserved. +func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata { + meta := audioMetadataFromEditFields(fields) + if existing == nil { + return meta + } + // Only overwrite fields that are present as keys in the edit set; otherwise + // keep the existing value. An empty value with the key present clears it. + keep := func(key, newVal, oldVal string) string { + if _, ok := fields[key]; ok { + return newVal + } + return oldVal + } + meta.Title = keep("title", meta.Title, existing.Title) + meta.Artist = keep("artist", meta.Artist, existing.Artist) + meta.Album = keep("album", meta.Album, existing.Album) + meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist) + meta.Genre = keep("genre", meta.Genre, existing.Genre) + meta.Composer = keep("composer", meta.Composer, existing.Composer) + meta.Label = keep("label", meta.Label, existing.Label) + meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright) + meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC) + meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics) + meta.Comment = keep("comment", meta.Comment, existing.Comment) + meta.Date = keep("date", meta.Date, existing.Date) + if _, ok := fields["track_number"]; !ok { + meta.TrackNumber = existing.TrackNumber + } + if _, ok := fields["track_total"]; !ok { + meta.TotalTracks = existing.TotalTracks + } + if _, ok := fields["disc_number"]; !ok { + meta.DiscNumber = existing.DiscNumber + } + if _, ok := fields["disc_total"]; !ok { + meta.TotalDiscs = existing.TotalDiscs + } + if _, ok := fields["replaygain_track_gain"]; !ok { + meta.ReplayGainTrackGain = existing.ReplayGainTrackGain + } + if _, ok := fields["replaygain_track_peak"]; !ok { + meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak + } + if _, ok := fields["replaygain_album_gain"]; !ok { + meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain + } + if _, ok := fields["replaygain_album_peak"]; !ok { + meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak + } + return meta +} + +// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk. +func WriteWAVTags(filePath string, fields map[string]string) error { + existing, _ := ReadWAVTags(filePath) + meta := mergeEditFieldsOntoExisting(existing, fields) + + coverData, coverMIME := loadCoverForTag(fields) + if coverData == nil { + // Preserve an existing embedded cover when no new one is supplied. + if f, err := os.Open(filePath); err == nil { + if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 { + coverData, coverMIME = extractAPICFromID3(p.id3) + } + f.Close() + } + } + + tag := buildID3v24Tag(meta, coverData, coverMIME) + return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag) +} + +// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk. +func WriteAIFFTags(filePath string, fields map[string]string) error { + existing, _ := ReadAIFFTags(filePath) + meta := mergeEditFieldsOntoExisting(existing, fields) + + coverData, coverMIME := loadCoverForTag(fields) + if coverData == nil { + if f, err := os.Open(filePath); err == nil { + if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 { + coverData, coverMIME = extractAPICFromID3(p.id3) + } + f.Close() + } + } + + tag := buildID3v24Tag(meta, coverData, coverMIME) + return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag) +} + +// ---------- library scan integration ---------- + +func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { + if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil { + applyAudioMetadataToScan(metadata, result) + } + if quality, err := GetWAVQuality(filePath); err == nil && quality != nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + result.Duration = quality.Duration + } + result.Bitrate = 0 // lossless PCM + result.Format = "wav" + applyDefaultLibraryMetadata(filePath, displayNameHint, result) + return result, nil +} + +func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { + if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil { + applyAudioMetadataToScan(metadata, result) + } + if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + result.Duration = quality.Duration + } + result.Bitrate = 0 // lossless PCM + result.Format = "aiff" + applyDefaultLibraryMetadata(filePath, displayNameHint, result) + return result, nil +} + +func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) { + result.TrackName = metadata.Title + result.ArtistName = metadata.Artist + result.AlbumName = metadata.Album + result.AlbumArtist = metadata.AlbumArtist + result.ISRC = metadata.ISRC + result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks + result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs + if metadata.Date != "" { + result.ReleaseDate = metadata.Date + } else { + result.ReleaseDate = metadata.Year + } + result.Genre = metadata.Genre + result.Composer = metadata.Composer + result.Label = metadata.Label + result.Copyright = metadata.Copyright +} + +// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a +// WAV or AIFF file, or an error when none is present. +func extractWAVAIFFCover(filePath string) ([]byte, string, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + f, err := os.Open(filePath) + if err != nil { + return nil, "", err + } + defer f.Close() + + var id3 []byte + switch ext { + case ".aiff", ".aif", ".aifc": + if p, perr := streamProbeAIFF(f); perr == nil { + id3 = p.id3 + } + default: + if p, perr := streamProbeWAV(f); perr == nil { + id3 = p.id3 + } + } + if len(id3) == 0 { + return nil, "", fmt.Errorf("no embedded cover") + } + data, mime := extractAPICFromID3(id3) + if len(data) == 0 { + return nil, "", fmt.Errorf("no embedded cover") + } + return data, mime, nil +} diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 9317452b..c3516a5d 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -968,8 +968,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; String selectedFormat = formats.first; - bool isLosslessTarget = - selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); String defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; if (format == 'AAC') return '256k'; @@ -1037,8 +1036,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - isLosslessTarget = - format == 'ALAC' || format == 'FLAC'; + isLosslessTarget = isLosslessConversionTarget( + format, + ); if (!isLosslessTarget) { selectedBitrate = defaultBitrateForFormat( format, @@ -1162,7 +1162,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } - final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLossless = isLosslessConversionTarget(targetFormat); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -1198,8 +1198,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final total = selected.length; final historyDb = HistoryDatabase.instance; final newQuality = - (targetFormat.toUpperCase() == 'ALAC' || - targetFormat.toUpperCase() == 'FLAC') + isLosslessConversionTarget(targetFormat) ? '${targetFormat.toUpperCase()} Lossless' : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); @@ -1304,27 +1303,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - String newExt; - String mimeType; - switch (targetFormat.toLowerCase()) { - case 'opus': - newExt = '.opus'; - mimeType = 'audio/opus'; - break; - case 'alac': - case 'aac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'flac': - newExt = '.flac'; - mimeType = 'audio/flac'; - break; - default: - newExt = '.mp3'; - mimeType = 'audio/mpeg'; - break; - } + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 3384235b..ed0c5908 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1216,8 +1216,7 @@ class _LocalAlbumScreenState extends ConsumerState { if (formats.isEmpty) return; String selectedFormat = formats.first; - bool isLosslessTarget = - selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); String defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; if (format == 'AAC') return '256k'; @@ -1285,8 +1284,9 @@ class _LocalAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - isLosslessTarget = - format == 'ALAC' || format == 'FLAC'; + isLosslessTarget = isLosslessConversionTarget( + format, + ); if (!isLosslessTarget) { selectedBitrate = defaultBitrateForFormat( format, @@ -1409,7 +1409,7 @@ class _LocalAlbumScreenState extends ConsumerState { return; } - final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLossless = isLosslessConversionTarget(targetFormat); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -1583,27 +1583,9 @@ class _LocalAlbumScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - String newExt; - String mimeType; - switch (targetFormat.toLowerCase()) { - case 'opus': - newExt = '.opus'; - mimeType = 'audio/opus'; - break; - case 'alac': - case 'aac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'flac': - newExt = '.flac'; - mimeType = 'audio/flac'; - break; - default: - newExt = '.mp3'; - mimeType = 'audio/mpeg'; - break; - } + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 63bb5406..a57bb496 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4836,8 +4836,7 @@ class _QueueTabState extends ConsumerState { if (formats.isEmpty) return; String selectedFormat = formats.first; - bool isLosslessTarget = - selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); String defaultBitrateForFormat(String format) { if (format == 'Opus') return '128k'; if (format == 'AAC') return '256k'; @@ -4909,8 +4908,9 @@ class _QueueTabState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - isLosslessTarget = - format == 'ALAC' || format == 'FLAC'; + isLosslessTarget = isLosslessConversionTarget( + format, + ); if (!isLosslessTarget) { selectedBitrate = defaultBitrateForFormat( format, @@ -5049,7 +5049,7 @@ class _QueueTabState extends ConsumerState { return; } - final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; + final isLossless = isLosslessConversionTarget(targetFormat); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -5085,8 +5085,7 @@ class _QueueTabState extends ConsumerState { final total = selectedItems.length; final historyDb = HistoryDatabase.instance; final newQuality = - (targetFormat.toUpperCase() == 'ALAC' || - targetFormat.toUpperCase() == 'FLAC') + isLosslessConversionTarget(targetFormat) ? '${targetFormat.toUpperCase()} Lossless' : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; final settings = ref.read(settingsProvider); @@ -5196,27 +5195,9 @@ class _QueueTabState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - String newExt; - String mimeType; - switch (targetFormat.toLowerCase()) { - case 'opus': - newExt = '.opus'; - mimeType = 'audio/opus'; - break; - case 'alac': - case 'aac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'flac': - newExt = '.flac'; - mimeType = 'audio/flac'; - break; - default: - newExt = '.mp3'; - mimeType = 'audio/mpeg'; - break; - } + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( @@ -5309,27 +5290,9 @@ class _QueueTabState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - String newExt; - String mimeType; - switch (targetFormat.toLowerCase()) { - case 'opus': - newExt = '.opus'; - mimeType = 'audio/opus'; - break; - case 'alac': - case 'aac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'flac': - newExt = '.flac'; - mimeType = 'audio/flac'; - break; - default: - newExt = '.mp3'; - mimeType = 'audio/mpeg'; - break; - } + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 82ce75cc..992612bc 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -27,6 +27,7 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; part 'track_metadata_edit_sheet.dart'; @@ -1739,6 +1740,8 @@ class _TrackMetadataScreenState extends ConsumerState { return switch (normalized) { 'flac' => 'FLAC', 'alac' => 'ALAC', + 'wav' || 'wave' => 'WAV', + 'aiff' || 'aif' || 'aifc' => 'AIFF', 'eac3' || 'ec_3' => 'EAC3', 'ac3' || 'ac_3' => 'AC3', 'ac4' || 'ac_4' => 'AC4', @@ -3320,6 +3323,7 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataOption( icon: Icons.share_outlined, label: l10n.trackMetadataShare, + dividerAbove: true, onTap: () => _shareFile(screenContext), ), _MetadataOption( @@ -3396,14 +3400,29 @@ class _TrackMetadataScreenState extends ConsumerState { height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - const SizedBox(height: 4), - for (final option in options) - _MetadataOptionTile( - option: option, - colorScheme: colorScheme, - onTap: () => - _closeOptionsMenuAndRun(sheetContext, option.onTap), - ), + const SizedBox(height: 8), + SettingsGroup( + children: [ + for (int i = 0; i < options.length; i++) ...[ + if (options[i].dividerAbove && i != 0) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), + ), + _MetadataOptionTile( + option: options[i], + colorScheme: colorScheme, + onTap: () => _closeOptionsMenuAndRun( + sheetContext, + options[i].onTap, + ), + ), + ], + ], + ), const SizedBox(height: 16), ], ), @@ -3591,7 +3610,7 @@ class _TrackMetadataScreenState extends ConsumerState { String _buildConvertedQualityLabel(String targetFormat, String bitrate) { final upper = targetFormat.toUpperCase(); - if (upper == 'ALAC' || upper == 'FLAC') { + if (isLosslessConversionTarget(targetFormat)) { return '$upper Lossless'; } final normalizedBitrate = bitrate.trim().toLowerCase(); @@ -3664,15 +3683,19 @@ class _TrackMetadataScreenState extends ConsumerState { void _showConvertSheet(BuildContext context) { final currentFormat = _currentFileFormat; - final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; + final isLosslessSource = isLosslessConversionSource(currentFormat); final formats = []; if (currentFormat == 'FLAC') { - formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']); + formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); } else if (currentFormat == 'ALAC') { - formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']); + formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); } else if (currentFormat == 'M4A') { - formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']); + formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'WAV') { + formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']); + } else if (currentFormat == 'AIFF') { + formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']); } else if (currentFormat == 'AAC') { formats.addAll(['MP3', 'Opus']); } else if (currentFormat == 'MP3') { @@ -3691,8 +3714,7 @@ class _TrackMetadataScreenState extends ConsumerState { } String selectedBitrate = defaultBitrateForFormat(selectedFormat); - bool isLosslessTarget = - selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; + bool isLosslessTarget = isLosslessConversionTarget(selectedFormat); showModalBottomSheet( context: context, @@ -3752,8 +3774,9 @@ class _TrackMetadataScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - isLosslessTarget = - format == 'ALAC' || format == 'FLAC'; + isLosslessTarget = isLosslessConversionTarget( + format, + ); if (!isLosslessTarget) { selectedBitrate = defaultBitrateForFormat( format, @@ -4306,9 +4329,7 @@ class _TrackMetadataScreenState extends ConsumerState { required String targetFormat, required String bitrate, }) { - final isLossless = - targetFormat.toUpperCase() == 'ALAC' || - targetFormat.toUpperCase() == 'FLAC'; + final isLossless = isLosslessConversionTarget(targetFormat); showDialog( context: context, builder: (dialogContext) { @@ -4515,30 +4536,9 @@ class _TrackMetadataScreenState extends ConsumerState { final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - String newExt; - String mimeType; - switch (targetFormat.toLowerCase()) { - case 'opus': - newExt = '.opus'; - mimeType = 'audio/opus'; - break; - case 'aac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'alac': - newExt = '.m4a'; - mimeType = 'audio/mp4'; - break; - case 'flac': - newExt = '.flac'; - mimeType = 'audio/flac'; - break; - default: - newExt = '.mp3'; - mimeType = 'audio/mpeg'; - break; - } + final convTarget = convertTargetExtAndMime(targetFormat); + final newExt = convTarget.ext; + final mimeType = convTarget.mime; final newFileName = '$baseName$newExt'; final safUri = await PlatformBridge.createSafFileFromPath( @@ -4955,12 +4955,14 @@ class _MetadataOption { final String label; final VoidCallback onTap; final bool destructive; + final bool dividerAbove; const _MetadataOption({ required this.icon, required this.label, required this.onTap, this.destructive = false, + this.dividerAbove = false, }); } @@ -4977,29 +4979,32 @@ class _MetadataOptionTile extends StatelessWidget { @override Widget build(BuildContext context) { - final boxColor = option.destructive - ? colorScheme.errorContainer - : colorScheme.primaryContainer; final iconColor = option.destructive - ? colorScheme.onErrorContainer - : colorScheme.onPrimaryContainer; + ? colorScheme.error + : colorScheme.onSurfaceVariant; final titleColor = option.destructive ? colorScheme.error : null; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: boxColor, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(option.icon, color: iconColor, size: 20), - ), - title: Text( - option.label, - style: TextStyle(fontWeight: FontWeight.w500, color: titleColor), - ), + return InkWell( onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Icon(option.icon, color: iconColor, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text( + option.label, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: titleColor), + ), + ), + ], + ), + ), ); } } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 657bea4f..f4796244 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -283,6 +284,28 @@ class FFmpegService { }.contains(normalized); } + /// Probes the source audio bit depth (bits_per_raw_sample, falling back to + /// bits_per_sample). Returns null when unknown. + static Future probeBitDepth(String filePath) async { + try { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + if (info == null) return null; + for (final stream in info.getStreams()) { + final props = stream.getAllProperties() ?? const {}; + if (props['codec_type']?.toString() != 'audio') continue; + final raw = props['bits_per_raw_sample']?.toString(); + final bps = props['bits_per_sample']?.toString(); + final v = int.tryParse(raw ?? '') ?? int.tryParse(bps ?? ''); + if (v != null && v > 0) return v; + return null; + } + } catch (e) { + _log.w('Bit depth probe failed for $filePath: $e'); + } + return null; + } + /// Returns `true` when [filePath] starts with the native FLAC magic bytes /// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4 /// container that carries a `.flac` extension or claims codec=flac. @@ -2119,8 +2142,9 @@ class FFmpegService { } /// Unified audio format conversion with full metadata + cover preservation. - /// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC. - /// ALAC and FLAC targets are lossless (bitrate parameter is ignored). + /// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF. + /// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored). + /// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF. static Future convertAudioFormat({ required String inputPath, required String targetFormat, @@ -2129,9 +2153,19 @@ class FFmpegService { String? coverPath, String artistTagMode = artistTagModeJoined, bool deleteOriginal = true, + int? sourceBitDepth, }) async { final format = targetFormat.toLowerCase(); - if (!const {'mp3', 'opus', 'aac', 'alac', 'flac'}.contains(format)) { + if (!const { + 'mp3', + 'opus', + 'aac', + 'alac', + 'flac', + 'wav', + 'aiff', + 'aif', + }.contains(format)) { _log.e('Unsupported target format: $targetFormat'); return null; } @@ -2153,6 +2187,16 @@ class FFmpegService { deleteOriginal: deleteOriginal, ); } + if (format == 'wav' || format == 'aiff' || format == 'aif') { + return _convertToPcm( + inputPath: inputPath, + metadata: metadata, + coverPath: coverPath, + container: format == 'wav' ? 'wav' : 'aiff', + sourceBitDepth: sourceBitDepth, + deleteOriginal: deleteOriginal, + ); + } final extension = switch (format) { 'opus' => '.opus', @@ -2390,6 +2434,205 @@ class FFmpegService { return outputPath; } + /// Convert to uncompressed PCM (WAV or AIFF), preserving bit depth when known. + /// Tags and cover are written natively into an embedded ID3 chunk by the Go + /// backend (RIFF "id3 " for WAV, "ID3 " for AIFF) for full-fidelity tagging. + static Future _convertToPcm({ + required String inputPath, + required Map metadata, + required String container, // 'wav' or 'aiff' + String? coverPath, + int? sourceBitDepth, + bool deleteOriginal = true, + }) async { + final isAiff = container == 'aiff'; + final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav'); + var depth = sourceBitDepth; + if (depth == null || depth <= 0) { + depth = await probeBitDepth(inputPath); + } + final use24 = depth != null && depth >= 24; + final codec = isAiff + ? (use24 ? 'pcm_s24be' : 'pcm_s16be') + : (use24 ? 'pcm_s24le' : 'pcm_s16le'); + + final arguments = [ + '-v', 'error', '-hide_banner', + '-i', inputPath, + '-map', '0:a', + '-c:a', codec, + '-map_metadata', '-1', + outputPath, + '-y', + ]; + + _log.i( + 'Converting ${inputPath.split(Platform.pathSeparator).last} to ' + '${container.toUpperCase()} (${use24 ? 24 : 16}-bit)', + ); + final result = await _executeWithArguments(arguments); + if (!result.success) { + _log.e('${container.toUpperCase()} conversion failed: ${result.output}'); + return null; + } + + // Write tags + cover via the native ID3-chunk writer in the Go backend. + final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); + final hasCover = coverPath != null && coverPath.trim().isNotEmpty; + if (hasMetadata || hasCover) { + final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath); + if (!ok) { + _log.w( + 'Native tag embed failed for $container output (file kept untagged)', + ); + } + } + + if (deleteOriginal) { + try { + await File(inputPath).delete(); + _log.i( + 'Deleted original: ${inputPath.split(Platform.pathSeparator).last}', + ); + } catch (e) { + _log.w('Failed to delete original: $e'); + } + } + + return outputPath; + } + + /// Writes tags + cover into a WAV/AIFF file via the Go native ID3-chunk + /// writer (PlatformBridge.editFileMetadata). Maps Vorbis-style metadata keys + /// to the lowercase field names the Go editor expects. + static Future _embedChunkTagsNative( + String path, + Map vorbisMetadata, + String? coverPath, + ) async { + final fields = _vorbisToNativeChunkFields(vorbisMetadata); + if (coverPath != null && coverPath.trim().isNotEmpty) { + fields['cover_path'] = coverPath; + } + if (fields.isEmpty) return true; + try { + final res = await PlatformBridge.editFileMetadata(path, fields); + return res['error'] == null; + } catch (e) { + _log.w('editFileMetadata for $path failed: $e'); + return false; + } + } + + /// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field + /// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer. + static Map _vorbisToNativeChunkFields( + Map metadata, + ) { + final out = {}; + + void setIndexPair(String numberKey, String totalKey, String value) { + final v = value.trim(); + if (v.isEmpty || v == '0') return; + if (v.contains('/')) { + final parts = v.split('/'); + out[numberKey] = parts[0].trim(); + if (parts.length > 1 && parts[1].trim().isNotEmpty) { + out[totalKey] = parts[1].trim(); + } + } else { + out[numberKey] = v; + } + } + + for (final entry in metadata.entries) { + final normalizedKey = entry.key.toUpperCase().replaceAll( + RegExp(r'[^A-Z0-9]'), + '', + ); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (normalizedKey) { + case 'TITLE': + out['title'] = value; + break; + case 'ARTIST': + out['artist'] = value; + break; + case 'ALBUM': + out['album'] = value; + break; + case 'ALBUMARTIST': + out['album_artist'] = value; + break; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + setIndexPair('track_number', 'track_total', value); + break; + case 'TRACKTOTAL': + case 'TOTALTRACKS': + if (value.trim() != '0') out['track_total'] = value.trim(); + break; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + setIndexPair('disc_number', 'disc_total', value); + break; + case 'DISCTOTAL': + case 'TOTALDISCS': + if (value.trim() != '0') out['disc_total'] = value.trim(); + break; + case 'DATE': + out['date'] = value; + break; + case 'YEAR': + if ((out['date'] ?? '').isEmpty) out['date'] = value; + break; + case 'ISRC': + out['isrc'] = value; + break; + case 'GENRE': + out['genre'] = value; + break; + case 'COMPOSER': + out['composer'] = value; + break; + case 'ORGANIZATION': + case 'LABEL': + case 'PUBLISHER': + out['label'] = value; + break; + case 'COPYRIGHT': + out['copyright'] = value; + break; + case 'COMMENT': + case 'DESCRIPTION': + out['comment'] = value; + break; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + out['lyrics'] = value; + break; + case 'REPLAYGAINTRACKGAIN': + out['replaygain_track_gain'] = value; + break; + case 'REPLAYGAINTRACKPEAK': + out['replaygain_track_peak'] = value; + break; + case 'REPLAYGAINALBUMGAIN': + out['replaygain_album_gain'] = value; + break; + case 'REPLAYGAINALBUMPEAK': + out['replaygain_album_peak'] = value; + break; + } + } + + return out; + } + /// Normalize metadata keys to standard Vorbis comment names, filtering out /// technical fields (bit_depth, sample_rate, duration, etc.). static Map _normalizeToVorbisComments( diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 5fa94dd8..4585b75f 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -2022,6 +2022,11 @@ class LibraryDatabase { return 'flac'; case 'opus': return 'opus'; + case 'wav': + return 'wav'; + case 'aiff': + case 'aif': + return 'aiff'; default: return 'mp3'; } diff --git a/lib/services/replaygain_service.dart b/lib/services/replaygain_service.dart index 68e28857..67024221 100644 --- a/lib/services/replaygain_service.dart +++ b/lib/services/replaygain_service.dart @@ -27,6 +27,10 @@ class ReplayGainService { '.ape', '.wv', '.mpc', + '.wav', + '.aiff', + '.aif', + '.aifc', }; static bool _isNativeWritableFormat(String path) { diff --git a/lib/utils/audio_conversion_utils.dart b/lib/utils/audio_conversion_utils.dart index da178a2f..55e1e8bc 100644 --- a/lib/utils/audio_conversion_utils.dart +++ b/lib/utils/audio_conversion_utils.dart @@ -1,6 +1,8 @@ const List audioConversionTargetFormats = [ 'ALAC', 'FLAC', + 'WAV', + 'AIFF', 'AAC', 'MP3', 'Opus', @@ -8,7 +10,11 @@ const List audioConversionTargetFormats = [ bool isLosslessConversionTarget(String targetFormat) { final normalized = targetFormat.trim().toLowerCase(); - return normalized == 'alac' || normalized == 'flac'; + return normalized == 'alac' || + normalized == 'flac' || + normalized == 'wav' || + normalized == 'aiff' || + normalized == 'aif'; } bool isLosslessConversionSource(String sourceFormat) { @@ -16,6 +22,9 @@ bool isLosslessConversionSource(String sourceFormat) { case 'FLAC': case 'ALAC': case 'M4A': + case 'WAV': + case 'AIFF': + case 'AIF': return true; default: return false; @@ -66,6 +75,13 @@ String? _convertibleAudioFormatLabel(String? rawFormat) { return 'FLAC'; case 'alac': return 'ALAC'; + case 'wav': + case 'wave': + return 'WAV'; + case 'aiff': + case 'aif': + case 'aifc': + return 'AIFF'; case 'm4a': case 'mp4': return 'M4A'; @@ -95,6 +111,28 @@ String normalizedConvertedAudioFormat(String targetFormat) { return targetFormat.trim().toLowerCase(); } +/// Returns the output file extension (with dot) and MIME type for a conversion +/// target format. Used when creating the converted file via SAF so WAV/AIFF and +/// the other formats get the correct extension + MIME. +({String ext, String mime}) convertTargetExtAndMime(String targetFormat) { + switch (targetFormat.trim().toLowerCase()) { + case 'opus': + return (ext: '.opus', mime: 'audio/opus'); + case 'alac': + case 'aac': + return (ext: '.m4a', mime: 'audio/mp4'); + case 'flac': + return (ext: '.flac', mime: 'audio/flac'); + case 'wav': + return (ext: '.wav', mime: 'audio/wav'); + case 'aiff': + case 'aif': + return (ext: '.aiff', mime: 'audio/aiff'); + default: + return (ext: '.mp3', mime: 'audio/mpeg'); + } +} + int? convertedAudioBitrateKbps({ required String targetFormat, required String bitrate, diff --git a/lib/utils/mime_utils.dart b/lib/utils/mime_utils.dart index ecee23f4..e87a0083 100644 --- a/lib/utils/mime_utils.dart +++ b/lib/utils/mime_utils.dart @@ -16,6 +16,10 @@ String audioMimeTypeForPath(String filePath) { return 'audio/ogg'; case 'wav': return 'audio/wav'; + case 'aiff': + case 'aif': + case 'aifc': + return 'audio/aiff'; case 'aac': return 'audio/aac'; default: diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index b9e02ca0..4e35cda0 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -19,6 +19,8 @@ const _audioExtensions = [ '.opus', '.ogg', '.wav', + '.aiff', + '.aif', '.aac', ];