diff --git a/go.mod b/go.mod index 277ae53..adf718c 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.1 ignore ./web/node_modules require ( - github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a + github.com/DiheMoe/gl3d v1.0.0 github.com/aws/aws-sdk-go-v2 v1.47.3 github.com/aws/aws-sdk-go-v2/credentials v1.20.9 github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4 diff --git a/go.sum b/go.sum index 92dc49f..48170dd 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s= -github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a h1:VIrsshIISZxzDBJ3aq5ASnfu880eYcA5vFol+YAWcM4= -github.com/DiheMoe/gl3d v0.0.0-20261022172000-ae409e94c25a/go.mod h1:lRy4c4S/LQ5hjKoHpDSZF3NQTRkDlUUt5aIL1RzjEN4= +github.com/DiheMoe/gl3d v1.0.0 h1:otOylwJk1efoTI8YLwcO/CE3+UWoSsN6ocftatrNNCQ= +github.com/DiheMoe/gl3d v1.0.0/go.mod h1:lRy4c4S/LQ5hjKoHpDSZF3NQTRkDlUUt5aIL1RzjEN4= github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= diff --git a/internal/app/prepare/media_test.go b/internal/app/prepare/media_test.go index 9267309..9cfd29c 100644 --- a/internal/app/prepare/media_test.go +++ b/internal/app/prepare/media_test.go @@ -180,10 +180,10 @@ func TestLinkRunnerFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) { } func TestLinkRunnerLogsAudioThatCannotBeDecoded(t *testing.T) { - decodeErr := truncatedMP3Error(t) + decodeErr := noAudioError(t) runWith := func(m track.Meta) (*bytes.Buffer, error) { logs := &bytes.Buffer{} - d := &downloader{meta: m, audio: truncatedMP3} + d := &downloader{meta: m, audio: noAudioMP3} err := newLinkRunner(d, porttest.NewMediaStore(), newTracks(), slog.New(slog.NewJSONHandler(logs, nil))).Run(t.Context(), link) return logs, err } diff --git a/internal/app/prepare/run.go b/internal/app/prepare/run.go index ab83782..1ce0070 100644 --- a/internal/app/prepare/run.go +++ b/internal/app/prepare/run.go @@ -52,6 +52,9 @@ func analyze(ctx context.Context, logger *slog.Logger, t track.Track, audio io.R prepared := track.Prepared{Duration: fallback, Gain: 1, Lyric: lyric} if err == nil { prepared.Duration, prepared.Gain = result.Duration, result.Coefficient() + if result.Damage != nil { + logger.WarnContext(ctx, "音频有损坏,增益和时长按解出的部分计算", "source", t.Source, "track_id", t.ID, "report", result.Damage) + } } else { logger.WarnContext(ctx, "音频无法解码,增益取 1、时长取元数据", "source", t.Source, "track_id", t.ID, "error", err) } diff --git a/internal/app/prepare/run_test.go b/internal/app/prepare/run_test.go index f12908e..ae1f1eb 100644 --- a/internal/app/prepare/run_test.go +++ b/internal/app/prepare/run_test.go @@ -37,11 +37,13 @@ var silentFrame = append([]byte{0xFF, 0xFB, 0x90, 0x00}, make([]byte, 413)...) var truncatedMP3 = append(bytes.Repeat(silentFrame, 3), silentFrame[:200]...) -func truncatedMP3Error(t *testing.T) error { +var noAudioMP3 = []byte("ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00") + +func noAudioError(t *testing.T) error { t.Helper() - _, err := loudness.AnalyzeReader(bytes.NewReader(truncatedMP3)) + _, err := loudness.AnalyzeReader(bytes.NewReader(noAudioMP3)) if err == nil { - t.Fatal("被截断的 MP3 应该无法解码") + t.Fatal("没有音频帧的 MP3 应该无法解码") } return err } @@ -83,6 +85,35 @@ func TestRunSavesWordAndTranslatedLyrics(t *testing.T) { } } +func TestRunStaysQuietForIntactAudio(t *testing.T) { + source := porttest.NewSource(track.NetEase) + source.Add("186016", meta, "", porttest.SilentWAV(time.Second)) + if _, logs, err := runLogged(t, source, sunny); err != nil || logs.Len() != 0 { + t.Errorf("错误 = %v,日志 = %q,期望完好的音频不写日志", err, logs) + } +} + +func TestRunMeasuresDamagedAudioAndWarns(t *testing.T) { + source := porttest.NewSource(track.NetEase) + source.Add("186016", meta, "", truncatedMP3) + tracks, logs, err := runLogged(t, source, sunny) + if err != nil { + t.Fatal(err) + } + // 3 帧、每帧 1152 个采样帧,44.1 kHz。 + if got, _ := tracks.row(sunny); got.Result.Duration >= time.Second || got.Result.Duration <= 0 { + t.Errorf("时长 = %v,期望取解出的部分,而不是元数据的 %v", got.Result.Duration, meta.Duration) + } + var entry map[string]any + if err := json.Unmarshal(logs.Bytes(), &entry); err != nil { + t.Fatalf("日志 = %q:%v", logs, err) + } + report, _ := entry["report"].(map[string]any) + if entry["level"] != "WARN" || entry["track_id"] != sunny.ID || report["truncated"] != true { + t.Errorf("日志 = %v,期望一条带异常报告的 WARN", entry) + } +} + func TestRunFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) { source := porttest.NewSource(track.NetEase) source.Add("186016", meta, "", []byte("不是音频")) @@ -103,7 +134,7 @@ func TestRunFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) { } func TestRunLogsAudioThatCannotBeDecoded(t *testing.T) { - decodeErr := truncatedMP3Error(t) + decodeErr := noAudioError(t) sourceWith := func(m track.Meta, audio []byte) port.Source { source := porttest.NewSource(track.NetEase) source.Add("186016", m, "", audio) @@ -111,14 +142,14 @@ func TestRunLogsAudioThatCannotBeDecoded(t *testing.T) { } t.Run("回退", func(t *testing.T) { - _, logs, err := runLogged(t, sourceWith(meta, truncatedMP3), sunny) + _, logs, err := runLogged(t, sourceWith(meta, noAudioMP3), sunny) if err != nil { t.Fatal(err) } checkDecodeWarning(t, logs, sunny, decodeErr) }) t.Run("元数据中也没有时长", func(t *testing.T) { - _, _, err := runLogged(t, sourceWith(track.Meta{Title: "晴天"}, truncatedMP3), sunny) + _, _, err := runLogged(t, sourceWith(track.Meta{Title: "晴天"}, noAudioMP3), sunny) var rejected *port.RejectedError if !errors.As(err, &rejected) || err.Error() != "这首歌的音频无法播放" { t.Errorf("错误 = %v,期望不能准备,原因带着解码的错误 %q", err, decodeErr) diff --git a/internal/lib/loudness/loudness.go b/internal/lib/loudness/loudness.go index 0630417..836da60 100644 --- a/internal/lib/loudness/loudness.go +++ b/internal/lib/loudness/loudness.go @@ -5,6 +5,7 @@ package loudness import ( "errors" "io" + "log/slog" "math" "time" @@ -21,6 +22,7 @@ type Result struct { Integrated float64 // 综合响度(LUFS),全静音时为 -Inf Peak float64 // 采样峰值(dBFS),全静音时为 -Inf Duration time.Duration + Damage slog.LogValuer // 解码时跳过或容错的异常,音频完好时为 nil;此时其余字段只反映解出的部分 } // target 的单位是 LUFS,返回的增益单位是 dB;静音时返回 0。 @@ -64,7 +66,12 @@ func AnalyzeReader(r io.Reader) (*Result, error) { return nil, err } } - return an.Result() + result, err := an.Result() + if err != nil { + return nil, err + } + result.Damage = wav.Damage(dec) + return result, nil } // samples 是按声道交错的 PCM 采样,满刻度为 1,可以超出 [-1, 1]。 diff --git a/internal/lib/loudness/mp3_test.go b/internal/lib/loudness/mp3_test.go index 791fa1b..7747c03 100644 --- a/internal/lib/loudness/mp3_test.go +++ b/internal/lib/loudness/mp3_test.go @@ -42,6 +42,9 @@ func TestAnalyzeReaderMP3MatchesReference(t *testing.T) { if err != nil { t.Fatal(err) } + if got.Damage != nil { + t.Errorf("完好的文件 Damage = %v,期望 nil", got.Damage.LogValue()) + } if math.Abs(got.Integrated-tt.integrated) > 0.05 { t.Errorf("Integrated = %.3f LUFS,期望 %.3f", got.Integrated, tt.integrated) } diff --git a/internal/lib/wav/decode_test.go b/internal/lib/wav/decode_test.go index 2f73cfb..25bcf98 100644 --- a/internal/lib/wav/decode_test.go +++ b/internal/lib/wav/decode_test.go @@ -227,7 +227,7 @@ func mp3Frame(header ...byte) []byte { var ( silentFrame = mp3Frame(0xFF, 0xFB, 0x90, 0x00) - layer2Frame = mp3Frame(0xFF, 0xFD, 0x90, 0x00) + layer2Frame = mp3Frame(0xFF, 0xFD, 0x80, 0x00) ) // 准备任务在 serve 进程中解码整首歌,稳态下每次 Read 都不能分配内存。 @@ -259,8 +259,8 @@ func TestDecodeRejectsUndecodableMP3(t *testing.T) { is func(error) bool }{ {"Layer II", bytes.Repeat(layer2Frame, 3), "不支持的 MP3 格式", asError[*gl3d.UnsupportedError]}, - {"最后一帧被截断", bytes.Repeat(silentFrame, 4)[:3*len(silentFrame)+200], "MP3 数据被截断", asError[*gl3d.TruncatedError]}, - {"ID3 标签声明的长度超过上限", []byte("ID3\x04\x00\x00\x7f\x7f\x7f\x7f\x00\x00"), "MP3 超出解码的资源上限", asError[*gl3d.LimitError]}, + {"只有标签", []byte("ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00"), "MP3 中没有音频帧", func(err error) bool { return errors.Is(err, gl3d.ErrNoAudio) }}, + {"首帧之前的数据超过上限", append([]byte("ID3\x04\x00\x00\x00\x00\x00\x00"), make([]byte, 70<<10)...), "MP3 超出解码的资源上限", asError[*gl3d.LimitError]}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -271,6 +271,34 @@ func TestDecodeRejectsUndecodableMP3(t *testing.T) { } } +func TestDamageReportsSkippedMP3Data(t *testing.T) { + decode := func(t *testing.T, file []byte) Decoder { + t.Helper() + dec, err := NewDecoder(bytes.NewReader(file)) + if err != nil { + t.Fatal(err) + } + if _, err := drainDecoder(dec); err != nil { + t.Fatal(err) + } + return dec + } + if got := Damage(decode(t, bytes.Repeat(silentFrame, 4))); got != nil { + t.Errorf("完好的 MP3:Damage = %v,期望 nil", got.LogValue()) + } + truncated := Damage(decode(t, bytes.Repeat(silentFrame, 4)[:3*len(silentFrame)+200])) + if truncated == nil || !strings.Contains(truncated.LogValue().String(), "truncated") { + t.Errorf("最后一帧被截断:Damage = %v,期望记下截断", truncated) + } + flac, err := os.ReadFile("testdata/sine.flac") + if err != nil { + t.Fatal(err) + } + if got := Damage(decode(t, flac)); got != nil { + t.Errorf("FLAC:Damage = %v,期望 nil", got.LogValue()) + } +} + type failingReader struct{ err error } func (r failingReader) Read([]byte) (int, error) { return 0, r.err } diff --git a/internal/lib/wav/mp3.go b/internal/lib/wav/mp3.go index 6b13034..43e08da 100644 --- a/internal/lib/wav/mp3.go +++ b/internal/lib/wav/mp3.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log/slog" "github.com/DiheMoe/gl3d" ) @@ -15,6 +16,7 @@ type mp3Decoder struct{ *gl3d.Decoder } // ctx 传 Background:取消靠 r 在 ctx 结束后读取失败(下载流,以及 prepare 中包了 ctx 的文件),gl3d 会原样返回这个错误; // WAV 和 FLAC 也是这样取消的,所以 Decoder 不接收 ctx。gl3d 自己的 ctx 只在每帧之前和每次读取之前检查, // 即使传入真实的 ctx,也至多少解码已缓冲的 64 KiB。 +// 用默认的宽松模式:解码只用来计算增益和时长,播放的是原文件,局部损坏时按解出的部分计算比整首放弃更接近实际。 func newMP3Decoder(r io.Reader) (Decoder, error) { dec, err := gl3d.NewDecoder(context.Background(), r) if err != nil { @@ -28,6 +30,18 @@ func (d mp3Decoder) Read(p []float64) (int, error) { return n * d.Channels(), mp3Error(err) } +// Damage 返回解码时跳过或容错的异常,用于写入日志;音频完好时返回 nil。解码结束后才是最终结果。 +func Damage(d Decoder) slog.LogValuer { + mp3, ok := d.(mp3Decoder) + if !ok { + return nil + } + if report := mp3.Report(); !report.Clean() { + return report + } + return nil +} + // io.EOF 原样返回,调用方靠它判断读完。 // 每次 Read 都经过这里:errors.As 的目标变量要在提前返回之后声明,否则它们会逃逸到堆上,使每次 Read 多分配四个对象。 func mp3Error(err error) error { @@ -36,8 +50,6 @@ func mp3Error(err error) error { } var ( unsupported *gl3d.UnsupportedError - truncated *gl3d.TruncatedError - corrupt *gl3d.CorruptError limit *gl3d.LimitError ) switch { @@ -45,10 +57,6 @@ func mp3Error(err error) error { return fmt.Errorf("wav: MP3 中没有音频帧: %w", err) case errors.As(err, &unsupported): return fmt.Errorf("wav: 不支持的 MP3 格式: %w", err) - case errors.As(err, &truncated): - return fmt.Errorf("wav: MP3 数据被截断: %w", err) - case errors.As(err, &corrupt): - return fmt.Errorf("wav: MP3 数据损坏: %w", err) case errors.As(err, &limit): return fmt.Errorf("wav: MP3 超出解码的资源上限: %w", err) default: