chore: upgrade gl3d to v1.0.0 and decode leniently

This commit is contained in:
DiheChen committed 2026-11-05 12:00:48 +08:00
1 parent 1a7f3cf2fc
commit 2fb58e001e
9 files changed
+101 -21

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+2 -2
View File
@@ -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
}
+3
View File
@@ -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)
}
+37 -6
View File
@@ -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)
+8 -1
View File
@@ -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]。
+3
View File
@@ -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)
}
+31 -3
View File
@@ -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 }
+14 -6
View File
@@ -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: