diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 7a906f8e..8f1b2921 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) { func isLyricsDescription(description string) bool { switch strings.ToLower(strings.TrimSpace(description)) { - case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc": + case + "lyrics", + "lyric", + "unsyncedlyrics", + "unsynced lyrics", + "uslt", + "lrc": return true default: return false diff --git a/go_backend/audio_metadata_mp3_test.go b/go_backend/audio_metadata_mp3_test.go new file mode 100644 index 00000000..b63f7b28 --- /dev/null +++ b/go_backend/audio_metadata_mp3_test.go @@ -0,0 +1,133 @@ +package gobackend + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func ffmpegCommand(args ...string) *exec.Cmd { + if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil { + return exec.Command(ffmpegPath, args...) + } + return exec.Command("ffmpeg", args...) +} + +func runFFmpegTestCommand(t *testing.T, args ...string) { + t.Helper() + cmd := ffmpegCommand(args...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ffmpeg failed: %v\n%s", err, string(output)) + } +} + +func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not available") + } + + tempDir := t.TempDir() + sourceFlac := filepath.Join(tempDir, "source.flac") + baseMp3 := filepath.Join(tempDir, "base.mp3") + finalMp3 := filepath.Join(tempDir, "final.mp3") + coverPath := filepath.Join(tempDir, "cover.jpg") + lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics" + + runFFmpegTestCommand( + t, + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "flac", + sourceFlac, + ) + + runFFmpegTestCommand( + t, + "-y", + "-f", + "lavfi", + "-i", + "color=c=red:s=32x32:d=1", + "-frames:v", + "1", + coverPath, + ) + + runFFmpegTestCommand( + t, + "-y", + "-i", + sourceFlac, + "-b:a", + "320k", + "-metadata", + "title=Test Song", + "-metadata", + "artist=Test Artist", + "-metadata", + "lyrics="+lyrics, + baseMp3, + ) + + runFFmpegTestCommand( + t, + "-y", + "-i", + baseMp3, + "-i", + coverPath, + "-map", + "0:a", + "-map_metadata", + "-1", + "-map", + "1:0", + "-c:v:0", + "copy", + "-id3v2_version", + "3", + "-metadata", + "title=Test Song", + "-metadata", + "artist=Test Artist", + "-metadata", + "lyrics="+lyrics, + "-metadata:s:v", + "title=Album cover", + "-metadata:s:v", + "comment=Cover (front)", + "-c:a", + "copy", + finalMp3, + ) + + meta, err := ReadID3Tags(finalMp3) + if err != nil { + t.Fatalf("ReadID3Tags failed: %v", err) + } + if meta == nil { + t.Fatalf("ReadID3Tags returned nil metadata") + } + + embeddedLyrics, err := ExtractLyrics(finalMp3) + if err != nil { + t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta) + } + if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") { + t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta) + } + if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") { + t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta) + } + + if _, err := os.Stat(finalMp3); err != nil { + t.Fatalf("expected final mp3 to exist: %v", err) + } +} diff --git a/go_backend/go.mod b/go_backend/go.mod index fe67fe7e..7b1e584a 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/refraction-networking/utls v1.8.2 golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/net v0.50.0 + golang.org/x/text v0.34.0 ) require ( @@ -24,6 +25,5 @@ require ( golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect ) diff --git a/go_backend/go.sum b/go_backend/go.sum index 50e29433..3b71ae9b 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= @@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7b6273a4..eb50e497 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5228,6 +5228,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Select playlists to delete'** String get selectionSelectPlaylistsToDelete; + + /// Title for audio analysis section + /// + /// In en, this message translates to: + /// **'Audio Quality Analysis'** + String get audioAnalysisTitle; + + /// Description for audio analysis tap-to-analyze prompt + /// + /// In en, this message translates to: + /// **'Verify lossless quality with spectrum analysis'** + String get audioAnalysisDescription; + + /// Loading text while analyzing audio + /// + /// In en, this message translates to: + /// **'Analyzing audio...'** + String get audioAnalysisAnalyzing; + + /// Sample rate metric label + /// + /// In en, this message translates to: + /// **'Sample Rate'** + String get audioAnalysisSampleRate; + + /// Bit depth metric label + /// + /// In en, this message translates to: + /// **'Bit Depth'** + String get audioAnalysisBitDepth; + + /// Channels metric label + /// + /// In en, this message translates to: + /// **'Channels'** + String get audioAnalysisChannels; + + /// Duration metric label + /// + /// In en, this message translates to: + /// **'Duration'** + String get audioAnalysisDuration; + + /// Nyquist frequency metric label + /// + /// In en, this message translates to: + /// **'Nyquist'** + String get audioAnalysisNyquist; + + /// File size metric label + /// + /// In en, this message translates to: + /// **'Size'** + String get audioAnalysisFileSize; + + /// Dynamic range metric label + /// + /// In en, this message translates to: + /// **'Dynamic Range'** + String get audioAnalysisDynamicRange; + + /// Peak amplitude metric label + /// + /// In en, this message translates to: + /// **'Peak'** + String get audioAnalysisPeak; + + /// RMS level metric label + /// + /// In en, this message translates to: + /// **'RMS'** + String get audioAnalysisRms; + + /// Total samples metric label + /// + /// In en, this message translates to: + /// **'Samples'** + String get audioAnalysisSamples; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f64670cd..5ca628be 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3087,4 +3087,44 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e1e437db..d15c8b31 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3055,4 +3055,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index dbaa5d7f..5c35784d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsEs extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9f0a9b86..591427f1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3056,4 +3056,44 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a63fe541..e69d5e81 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3054,4 +3054,44 @@ class AppLocalizationsHi extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 67f85972..3fc89c44 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3064,4 +3064,44 @@ class AppLocalizationsId extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index c759276c..d4af16ec 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3041,4 +3041,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6d1993e2..e9af2187 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3034,4 +3034,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 054c9667..521ab98b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3054,4 +3054,44 @@ class AppLocalizationsNl extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1c9aba43..b81bdbdc 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsPt extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8348e0b7..13af38da 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3114,4 +3114,44 @@ class AppLocalizationsRu extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 70180433..7050af84 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3060,4 +3060,44 @@ class AppLocalizationsTr extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e52acfad..04a1fd43 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsZh extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ed71d48f..02ef0a43 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4015,5 +4015,57 @@ "selectionSelectPlaylistsToDelete": "Select playlists to delete", "@selectionSelectPlaylistsToDelete": { "description": "Hint shown when no playlists are selected for deletion" + }, + "audioAnalysisTitle": "Audio Quality Analysis", + "@audioAnalysisTitle": { + "description": "Title for audio analysis section" + }, + "audioAnalysisDescription": "Verify lossless quality with spectrum analysis", + "@audioAnalysisDescription": { + "description": "Description for audio analysis tap-to-analyze prompt" + }, + "audioAnalysisAnalyzing": "Analyzing audio...", + "@audioAnalysisAnalyzing": { + "description": "Loading text while analyzing audio" + }, + "audioAnalysisSampleRate": "Sample Rate", + "@audioAnalysisSampleRate": { + "description": "Sample rate metric label" + }, + "audioAnalysisBitDepth": "Bit Depth", + "@audioAnalysisBitDepth": { + "description": "Bit depth metric label" + }, + "audioAnalysisChannels": "Channels", + "@audioAnalysisChannels": { + "description": "Channels metric label" + }, + "audioAnalysisDuration": "Duration", + "@audioAnalysisDuration": { + "description": "Duration metric label" + }, + "audioAnalysisNyquist": "Nyquist", + "@audioAnalysisNyquist": { + "description": "Nyquist frequency metric label" + }, + "audioAnalysisFileSize": "Size", + "@audioAnalysisFileSize": { + "description": "File size metric label" + }, + "audioAnalysisDynamicRange": "Dynamic Range", + "@audioAnalysisDynamicRange": { + "description": "Dynamic range metric label" + }, + "audioAnalysisPeak": "Peak", + "@audioAnalysisPeak": { + "description": "Peak amplitude metric label" + }, + "audioAnalysisRms": "RMS", + "@audioAnalysisRms": { + "description": "RMS level metric label" + }, + "audioAnalysisSamples": "Samples", + "@audioAnalysisSamples": { + "description": "Total samples metric label" } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 09f0175c..c27f426b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; final _log = AppLogger('TrackMetadata'); @@ -770,6 +771,11 @@ class _TrackMetadataScreenState extends ConsumerState { _buildLyricsCard(context, colorScheme), + if (_fileExists) ...[ + const SizedBox(height: 16), + AudioAnalysisCard(filePath: _filePath), + ], + const SizedBox(height: 24), _buildActionButtons(context, ref, colorScheme, _fileExists), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 0c4447cf..313f2f40 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -92,9 +92,9 @@ class PlatformBridge { } static Stream> downloadProgressStream() { - return _downloadProgressEvents - .receiveBroadcastStream() - .map(_decodeMapResult); + return _downloadProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } static Future exitApp() async { @@ -1184,9 +1184,9 @@ class PlatformBridge { } static Stream> libraryScanProgressStream() { - return _libraryScanProgressEvents - .receiveBroadcastStream() - .map(_decodeMapResult); + return _libraryScanProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } /// Cancel ongoing library scan diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart new file mode 100644 index 00000000..09822fef --- /dev/null +++ b/lib/widgets/audio_analysis_widget.dart @@ -0,0 +1,1019 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/level.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +// --------------------------------------------------------------------------- +// Data models +// --------------------------------------------------------------------------- + +class AudioAnalysisData { + final String filePath; + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + final String bitDepth; + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const AudioAnalysisData({ + required this.filePath, + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + required this.bitDepth, + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); +} + +class SpectrogramData { + final List magnitudes; // [timeSlice][freqBin] + final int sampleRate; + final int freqBins; + final double duration; + final double maxFreq; + final int sliceCount; + + const SpectrogramData({ + required this.magnitudes, + required this.sampleRate, + required this.freqBins, + required this.duration, + required this.maxFreq, + required this.sliceCount, + }); +} + +// --------------------------------------------------------------------------- +// Audio Analysis Card Widget +// --------------------------------------------------------------------------- + +class AudioAnalysisCard extends StatefulWidget { + final String filePath; + + const AudioAnalysisCard({super.key, required this.filePath}); + + @override + State createState() => _AudioAnalysisCardState(); +} + +class _AudioAnalysisCardState extends State { + AudioAnalysisData? _data; + bool _analyzing = false; + String? _error; + ui.Image? _spectrogramImage; + + static const _supportedExtensions = { + '.flac', + '.mp3', + '.m4a', + '.aac', + '.opus', + '.ogg', + '.wav', + '.wma', + }; + + bool get _isSupported { + final lower = widget.filePath.toLowerCase(); + return _supportedExtensions.any((ext) => lower.endsWith(ext)); + } + + @override + void dispose() { + _spectrogramImage?.dispose(); + super.dispose(); + } + + Future _analyze() async { + if (_analyzing) return; + setState(() { + _analyzing = true; + _error = null; + }); + + try { + final data = await _runAnalysis(widget.filePath); + + ui.Image? image; + if (data.spectrum != null && data.spectrum!.sliceCount > 0) { + image = await _renderSpectrogramToImage(data.spectrum!); + } + + if (mounted) { + setState(() { + _data = data; + _spectrogramImage?.dispose(); + _spectrogramImage = image; + _analyzing = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _analyzing = false; + }); + } + } + } + + // ------------------------------------------------------------------------- + // Analysis pipeline: FFprobe metadata + FFmpeg PCM decode + FFT in isolate + // ------------------------------------------------------------------------- + + Future _runAnalysis(String filePath) async { + // Suppress FFmpegKit verbose logging (metadata/lyrics dump) + await FFmpegKitConfig.setLogLevel(Level.avLogError); + + // Handle SAF content:// URIs by copying to temp first + String workingPath = filePath; + String? tempCopy; + if (filePath.startsWith('content://')) { + tempCopy = await PlatformBridge.copyContentUriToTemp(filePath); + if (tempCopy == null) { + throw Exception('Failed to copy SAF file for analysis'); + } + workingPath = tempCopy; + } + + try { + // 1. Get metadata via FFprobe + final info = await _getMediaInfo(workingPath); + + // 2. Decode to raw PCM via FFmpeg + final tempDir = await getTemporaryDirectory(); + final pcmPath = + '${tempDir.path}/analysis_pcm_${DateTime.now().millisecondsSinceEpoch}.raw'; + + try { + await _decodeToPCM(workingPath, pcmPath, info.sampleRate); + + // 3. Read PCM + compute FFT + metrics in isolate + final pcmBytes = await File(pcmPath).readAsBytes(); + final result = await compute( + _analyzeInIsolate, + _AnalysisParams( + pcmBytes: pcmBytes, + sampleRate: info.sampleRate, + bitsPerSample: info.bitsPerSample, + ), + ); + + return AudioAnalysisData( + filePath: filePath, + fileSize: info.fileSize, + sampleRate: info.sampleRate, + channels: info.channels, + bitsPerSample: info.bitsPerSample, + duration: info.duration, + bitrate: info.bitrate, + bitDepth: info.bitsPerSample > 0 + ? '${info.bitsPerSample}-bit' + : 'N/A', + dynamicRange: result.dynamicRange, + peakAmplitude: result.peakAmplitude, + rmsLevel: result.rmsLevel, + totalSamples: result.totalSamples, + spectrum: result.spectrum, + ); + } finally { + try { + await File(pcmPath).delete(); + } catch (_) {} + } + } finally { + if (tempCopy != null) { + try { + await File(tempCopy).delete(); + } catch (_) {} + } + // Restore default log level + await FFmpegKitConfig.setLogLevel(Level.avLogInfo); + } + } + + Future<_MediaInfo> _getMediaInfo(String filePath) async { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + + if (info == null) { + throw Exception('Failed to get media information'); + } + + int fileSize = 0; + try { + fileSize = await File(filePath).length(); + } catch (_) {} + + final streams = info.getStreams(); + final audioStream = streams.firstWhere( + (s) => s.getAllProperties()?['codec_type'] == 'audio', + orElse: () => throw Exception('No audio stream found'), + ); + + final props = audioStream.getAllProperties() ?? {}; + final sampleRate = + int.tryParse(props['sample_rate']?.toString() ?? '') ?? 0; + final channels = int.tryParse(props['channels']?.toString() ?? '') ?? 0; + final duration = + double.tryParse( + info.getDuration() ?? props['duration']?.toString() ?? '', + ) ?? + 0; + final bitrate = + int.tryParse( + info.getBitrate() ?? props['bit_rate']?.toString() ?? '', + ) ?? + 0; + + int bitsPerSample = + int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0; + if (bitsPerSample == 0) { + bitsPerSample = + int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; + } + + // For lossy formats, infer bit depth from sample format + if (bitsPerSample == 0) { + final sampleFmt = props['sample_fmt']?.toString() ?? ''; + if (sampleFmt.contains('16') || + sampleFmt == 's16' || + sampleFmt == 's16p') { + bitsPerSample = 16; + } else if (sampleFmt.contains('32') || + sampleFmt == 'flt' || + sampleFmt == 'fltp') { + bitsPerSample = 32; + } else if (sampleFmt.contains('24') || sampleFmt == 's24') { + bitsPerSample = 24; + } + } + + return _MediaInfo( + fileSize: fileSize, + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample, + duration: duration, + bitrate: bitrate, + ); + } + + Future _decodeToPCM( + String inputPath, + String outputPath, + int sampleRate, + ) async { + // Decode to mono 16-bit signed LE PCM, limit to ~10M samples + final maxDuration = sampleRate > 0 ? (10000000 / sampleRate) : 300; + + final session = await FFmpegKit.executeWithArguments([ + '-loglevel', 'error', + '-i', inputPath, + '-t', maxDuration.toStringAsFixed(1), + '-ac', '1', // mono + '-ar', sampleRate.toString(), + '-f', 's16le', // 16-bit signed little-endian PCM + '-acodec', 'pcm_s16le', + '-y', outputPath, + ]); + + final returnCode = await session.getReturnCode(); + if (!ReturnCode.isSuccess(returnCode)) { + final logs = await session.getLogsAsString(); + throw Exception('FFmpeg decode failed: $logs'); + } + } + + Future _renderSpectrogramToImage(SpectrogramData spectrum) async { + const imgWidth = 800; + const imgHeight = 400; + + final pixels = await compute( + _renderSpectrogramPixels, + _SpectrogramRenderParams( + spectrum: spectrum, + width: imgWidth, + height: imgHeight, + ), + ); + + final completer = Completer(); + ui.decodeImageFromPixels( + pixels, + imgWidth, + imgHeight, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + @override + Widget build(BuildContext context) { + if (!_isSupported) return const SizedBox.shrink(); + + final cs = Theme.of(context).colorScheme; + final l10n = context.l10n; + + if (_analyzing) { + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + const SizedBox(height: 12), + Text( + l10n.audioAnalysisAnalyzing, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ), + ); + } + + if (_error != null) { + return Card( + color: cs.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: cs.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _error!, + style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + ), + ), + ], + ), + ), + ); + } + + if (_data == null) { + return Card( + color: cs.surfaceContainerLow, + child: InkWell( + onTap: _analyze, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 28), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + l10n.audioAnalysisDescription, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ), + ), + ); + } + + final data = _data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AudioInfoCard(data: data), + if (_spectrogramImage != null) ...[ + const SizedBox(height: 12), + _SpectrogramView(image: _spectrogramImage!, spectrum: data.spectrum!), + ], + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +class _MediaInfo { + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + + const _MediaInfo({ + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + }); +} + +class _AnalysisParams { + final Uint8List pcmBytes; + final int sampleRate; + final int bitsPerSample; + + const _AnalysisParams({ + required this.pcmBytes, + required this.sampleRate, + required this.bitsPerSample, + }); +} + +class _AnalysisResult { + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const _AnalysisResult({ + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); +} + +// --------------------------------------------------------------------------- +// Isolate: PCM → metrics + FFT spectrogram (all CPU, no GPU) +// --------------------------------------------------------------------------- + +_AnalysisResult _analyzeInIsolate(_AnalysisParams params) { + // Decode 16-bit signed LE PCM to normalized float samples + final byteData = ByteData.sublistView(params.pcmBytes); + final sampleCount = params.pcmBytes.length ~/ 2; // 16-bit = 2 bytes + final samples = Float64List(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + final raw = byteData.getInt16(i * 2, Endian.little); + samples[i] = raw / 32768.0; + } + + // Audio metrics + double peak = 0; + double sumSquares = 0; + for (int i = 0; i < samples.length; i++) { + final abs = samples[i].abs(); + if (abs > peak) peak = abs; + sumSquares += samples[i] * samples[i]; + } + + final peakDB = peak > 0 ? 20.0 * math.log(peak) / math.ln10 : -100.0; + final rms = math.sqrt(sumSquares / samples.length); + final rmsDB = rms > 0 ? 20.0 * math.log(rms) / math.ln10 : -100.0; + + // FFT spectrogram + SpectrogramData? spectrum; + if (samples.length >= 8192) { + spectrum = _computeSpectrum(samples, params.sampleRate); + } + + return _AnalysisResult( + dynamicRange: peakDB - rmsDB, + peakAmplitude: peakDB, + rmsLevel: rmsDB, + totalSamples: sampleCount, + spectrum: spectrum, + ); +} + +SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { + const fftSize = 8192; + const numSlices = 300; + const freqBins = fftSize ~/ 2; + + final duration = samples.length / sampleRate; + var samplesPerSlice = samples.length ~/ numSlices; + var actualSlices = numSlices; + if (samplesPerSlice < fftSize) { + samplesPerSlice = fftSize; + actualSlices = samples.length ~/ fftSize; + } + + final magnitudes = []; + + for (int i = 0; i < actualSlices; i++) { + final start = i * samplesPerSlice; + if (start + fftSize > samples.length) break; + + // Apply Hann window + final windowed = Float64List(fftSize); + for (int j = 0; j < fftSize; j++) { + final w = 0.5 * (1.0 - math.cos(2.0 * math.pi * j / (fftSize - 1))); + windowed[j] = samples[start + j] * w; + } + + // FFT + final spectrum = _fft(windowed); + + // Magnitude in dB + final mags = Float64List(freqBins); + for (int j = 0; j < freqBins; j++) { + final re = spectrum[j * 2]; + final im = spectrum[j * 2 + 1]; + var mag = math.sqrt(re * re + im * im); + if (mag < 1e-10) mag = 1e-10; + mags[j] = 20.0 * math.log(mag) / math.ln10; + } + magnitudes.add(mags); + } + + return SpectrogramData( + magnitudes: magnitudes, + sampleRate: sampleRate, + freqBins: freqBins, + duration: duration, + maxFreq: sampleRate / 2.0, + sliceCount: magnitudes.length, + ); +} + +/// Cooley-Tukey radix-2 FFT. Returns interleaved [re0, im0, re1, im1, ...]. +Float64List _fft(Float64List realInput) { + final n = realInput.length; + // Interleaved complex: [re, im, re, im, ...] + final data = Float64List(n * 2); + for (int i = 0; i < n; i++) { + data[i * 2] = realInput[i]; + } + + // Bit-reversal permutation + int j = 0; + for (int i = 0; i < n; i++) { + if (i < j) { + final tr = data[i * 2]; + final ti = data[i * 2 + 1]; + data[i * 2] = data[j * 2]; + data[i * 2 + 1] = data[j * 2 + 1]; + data[j * 2] = tr; + data[j * 2 + 1] = ti; + } + int m = n >> 1; + while (m >= 1 && j >= m) { + j -= m; + m >>= 1; + } + j += m; + } + + // Iterative FFT + for (int size = 2; size <= n; size <<= 1) { + final halfSize = size >> 1; + final angle = -2.0 * math.pi / size; + final wRe = math.cos(angle); + final wIm = math.sin(angle); + + for (int i = 0; i < n; i += size) { + double curRe = 1.0; + double curIm = 0.0; + + for (int k = 0; k < halfSize; k++) { + final evenIdx = (i + k) * 2; + final oddIdx = (i + k + halfSize) * 2; + + final tRe = curRe * data[oddIdx] - curIm * data[oddIdx + 1]; + final tIm = curRe * data[oddIdx + 1] + curIm * data[oddIdx]; + + data[oddIdx] = data[evenIdx] - tRe; + data[oddIdx + 1] = data[evenIdx + 1] - tIm; + data[evenIdx] += tRe; + data[evenIdx + 1] += tIm; + + final newRe = curRe * wRe - curIm * wIm; + curIm = curRe * wIm + curIm * wRe; + curRe = newRe; + } + } + } + + return data; +} + +// --------------------------------------------------------------------------- +// Audio Info Card (metrics) +// --------------------------------------------------------------------------- + +class _AudioInfoCard extends StatelessWidget { + final AudioAnalysisData data; + + const _AudioInfoCard({required this.data}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final nyquist = data.sampleRate / 2; + + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text( + context.l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.graphic_eq, + label: context.l10n.audioAnalysisSampleRate, + value: '${(data.sampleRate / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + _MetricChip( + icon: Icons.audio_file, + label: context.l10n.audioAnalysisBitDepth, + value: data.bitDepth, + cs: cs, + ), + _MetricChip( + icon: Icons.surround_sound, + label: context.l10n.audioAnalysisChannels, + value: data.channels == 2 + ? 'Stereo' + : data.channels == 1 + ? 'Mono' + : '${data.channels}', + cs: cs, + ), + _MetricChip( + icon: Icons.timer_outlined, + label: context.l10n.audioAnalysisDuration, + value: _formatDuration(data.duration), + cs: cs, + ), + _MetricChip( + icon: Icons.speed, + label: context.l10n.audioAnalysisNyquist, + value: '${(nyquist / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + if (data.fileSize > 0) + _MetricChip( + icon: Icons.storage, + label: context.l10n.audioAnalysisFileSize, + value: _formatFileSize(data.fileSize), + cs: cs, + ), + ], + ), + const SizedBox(height: 8), + Divider(color: cs.outlineVariant), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.trending_up, + label: context.l10n.audioAnalysisDynamicRange, + value: '${data.dynamicRange.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.show_chart, + label: context.l10n.audioAnalysisPeak, + value: '${data.peakAmplitude.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.equalizer, + label: context.l10n.audioAnalysisRms, + value: '${data.rmsLevel.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.numbers, + label: context.l10n.audioAnalysisSamples, + value: _formatNumber(data.totalSamples), + cs: cs, + ), + ], + ), + ], + ), + ), + ); + } + + String _formatDuration(double seconds) { + final mins = seconds ~/ 60; + final secs = (seconds % 60).floor(); + return '$mins:${secs.toString().padLeft(2, '0')}'; + } + + String _formatFileSize(int bytes) { + if (bytes == 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + final i = (math.log(bytes) / math.log(1024)).floor(); + final size = bytes / math.pow(1024, i); + return '${size.toStringAsFixed(1)} ${units[i]}'; + } + + String _formatNumber(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; + return n.toString(); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final ColorScheme cs; + + const _MetricChip({ + required this.icon, + required this.label, + required this.value, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Spectrogram View +// --------------------------------------------------------------------------- + +class _SpectrogramView extends StatelessWidget { + final ui.Image image; + final SpectrogramData spectrum; + + const _SpectrogramView({required this.image, required this.spectrum}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Card( + color: Colors.black, + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 2.0, + child: CustomPaint( + painter: _ImagePainter(image), + size: Size.infinite, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Text( + '${context.l10n.audioAnalysisSampleRate}: ${spectrum.sampleRate} Hz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + const Spacer(), + Text( + '${context.l10n.audioAnalysisNyquist}: ${(spectrum.maxFreq / 1000).toStringAsFixed(1)} kHz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ImagePainter extends CustomPainter { + final ui.Image image; + _ImagePainter(this.image); + + @override + void paint(Canvas canvas, Size size) { + paintImage( + canvas: canvas, + rect: Offset.zero & size, + image: image, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(covariant _ImagePainter old) => old.image != image; +} + +// --------------------------------------------------------------------------- +// Spectrogram pixel-buffer rendering (runs in isolate) +// --------------------------------------------------------------------------- + +class _SpectrogramRenderParams { + final SpectrogramData spectrum; + final int width; + final int height; + + const _SpectrogramRenderParams({ + required this.spectrum, + required this.width, + required this.height, + }); +} + +Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { + final w = params.width; + final h = params.height; + final spectrum = params.spectrum; + final pixels = Uint8List(w * h * 4); + + // Fill black with full alpha + for (int i = 3; i < pixels.length; i += 4) { + pixels[i] = 255; + } + + final slices = spectrum.magnitudes; + if (slices.isEmpty) return pixels; + + final freqBins = spectrum.freqBins; + + // Calculate dB range + double minDB = 0; + double maxDB = -200; + for (final slice in slices) { + for (int i = 0; i < slice.length; i++) { + final db = slice[i]; + if (db > maxDB) maxDB = db; + if (db < minDB && db > -200) minDB = db; + } + } + minDB = math.max(minDB, maxDB - 90); + final dbRange = maxDB - minDB; + if (dbRange <= 0) return pixels; + + for (int px = 0; px < w; px++) { + final t = (px / w * slices.length).floor().clamp(0, slices.length - 1); + final slice = slices[t]; + + for (int py = 0; py < h; py++) { + final freqRatio = 1.0 - (py / h); + final f = (freqRatio * freqBins).floor().clamp(0, freqBins - 1); + if (f >= slice.length) continue; + + final db = slice[f]; + final intensity = ((db - minDB) / dbRange).clamp(0.0, 1.0); + final color = _spekColorRGB(intensity); + + final offset = (py * w + px) * 4; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + pixels[offset + 3] = 255; + } + } + + return pixels; +} + +List _spekColorRGB(double intensity) { + int r, g, b; + if (intensity < 0.08) { + final t = intensity / 0.08; + r = 0; + g = 0; + b = (t * 80).floor(); + } else if (intensity < 0.18) { + final t = (intensity - 0.08) / 0.10; + r = (t * 50).floor(); + g = (t * 30).floor(); + b = (80 + t * 175).floor(); + } else if (intensity < 0.28) { + final t = (intensity - 0.18) / 0.10; + r = (50 + t * 150).floor(); + g = (30 - t * 30).floor(); + b = (255 - t * 55).floor(); + } else if (intensity < 0.40) { + final t = (intensity - 0.28) / 0.12; + r = (200 + t * 55).floor(); + g = 0; + b = (200 - t * 200).floor(); + } else if (intensity < 0.52) { + final t = (intensity - 0.40) / 0.12; + r = 255; + g = (t * 100).floor(); + b = 0; + } else if (intensity < 0.65) { + final t = (intensity - 0.52) / 0.13; + r = 255; + g = (100 + t * 80).floor(); + b = 0; + } else if (intensity < 0.78) { + final t = (intensity - 0.65) / 0.13; + r = 255; + g = (180 + t * 55).floor(); + b = (t * 30).floor(); + } else if (intensity < 0.90) { + final t = (intensity - 0.78) / 0.12; + r = 255; + g = (235 + t * 20).floor(); + b = (30 + t * 100).floor(); + } else { + final t = (intensity - 0.90) / 0.10; + r = 255; + g = 255; + b = (130 + t * 125).floor(); + } + return [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]; +}