mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-29 17:50:00 +02:00
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.
This commit is contained in:
@@ -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 -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -968,8 +968,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -1198,8 +1198,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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(
|
||||
|
||||
@@ -1216,8 +1216,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -1583,27 +1583,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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(
|
||||
|
||||
+12
-49
@@ -4836,8 +4836,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -5085,8 +5085,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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(
|
||||
|
||||
@@ -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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
_MetadataOption(
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.trackMetadataShare,
|
||||
dividerAbove: true,
|
||||
onTap: () => _shareFile(screenContext),
|
||||
),
|
||||
_MetadataOption(
|
||||
@@ -3396,14 +3400,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
|
||||
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<TrackMetadataScreen> {
|
||||
|
||||
void _showConvertSheet(BuildContext context) {
|
||||
final currentFormat = _currentFileFormat;
|
||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
final isLosslessSource = isLosslessConversionSource(currentFormat);
|
||||
|
||||
final formats = <String>[];
|
||||
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<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -3752,8 +3774,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
required String targetFormat,
|
||||
required String bitrate,
|
||||
}) {
|
||||
final isLossless =
|
||||
targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
@@ -4515,30 +4536,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int?> 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 <String, dynamic>{};
|
||||
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<String?> 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<String?> _convertToPcm({
|
||||
required String inputPath,
|
||||
required Map<String, String> 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 = <String>[
|
||||
'-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<bool> _embedChunkTagsNative(
|
||||
String path,
|
||||
Map<String, String> 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<String, String> _vorbisToNativeChunkFields(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final out = <String, String>{};
|
||||
|
||||
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<String, String> _normalizeToVorbisComments(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ class ReplayGainService {
|
||||
'.ape',
|
||||
'.wv',
|
||||
'.mpc',
|
||||
'.wav',
|
||||
'.aiff',
|
||||
'.aif',
|
||||
'.aifc',
|
||||
};
|
||||
|
||||
static bool _isNativeWritableFormat(String path) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const List<String> audioConversionTargetFormats = [
|
||||
'ALAC',
|
||||
'FLAC',
|
||||
'WAV',
|
||||
'AIFF',
|
||||
'AAC',
|
||||
'MP3',
|
||||
'Opus',
|
||||
@@ -8,7 +10,11 @@ const List<String> 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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,8 @@ const _audioExtensions = <String>[
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.aiff',
|
||||
'.aif',
|
||||
'.aac',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user