@@ -206,6 +206,93 @@ type Cut struct {
206206 Volume float64
207207}
208208
209+ // errEmptyAudioCut 表示“这次裁剪在逻辑上不应该播放任何声音”。
210+ // 这不是异常故障, 而是一个明确的业务分支:
211+ // 1. 用户把结束时间拖到了开始时间之前/相同位置;
212+ // 2. 裁剪区间完全落在音频总长度之外;
213+ // 3. 音频长度本身异常, 无法得到任何可播放样本。
214+ //
215+ // 之所以单独定义这个错误, 是为了在调用方区分:
216+ // - 真正需要记录日志的故障(如 Seek 失败、解码器异常);
217+ // - 合法但不应发声的“空片段”情况。
218+ var errEmptyAudioCut = errors .New ("audio cut does not contain playable samples" )
219+
220+ // preparePlaybackSource 将“裁剪描述 Cut”转换成一个真正可播放的 Streamer。
221+ //
222+ // 这是这次修复的核心: 旧实现是先 Resample, 再在播放期间轮询 Position(),
223+ // 一旦发现到达结束点就直接 Close 原始流。这个做法的问题在于:
224+ // 1. Resampler 为了插值会预读未来的一段原始样本;
225+ // 2. 因此“原始流当前位置”并不等于“已经真正播出的声音位置”;
226+ // 3. 尤其在很短的裁剪片段中, 预读进来的区间外样本会被插值带入输出;
227+ // 4. 最终表现就是: 明明选中的片段没有声音, 实际却还能听到片段外的声音。
228+ //
229+ // 新方案改成两步:
230+ // 1. 先在原始采样率的 StreamSeekCloser 上 Seek 到 startSample;
231+ // 2. 再用 beep.Take 严格限制最多只能读取 end-start 个样本;
232+ //
233+ // 这样重采样器拿到的输入源本身就是一个“已经裁好长度”的只读片段,
234+ // 它即便预读, 也只能在这个受限区间内预读, 不可能再越界读到片段外的声音。
235+ //
236+ // 返回值:
237+ // - beep.Streamer: 供后续重采样与播放使用的源流;
238+ // - float64: 初始音量偏移, 直接继承自 cut.Volume;
239+ // - error: 区分空片段(errEmptyAudioCut)与真正故障。
240+ func preparePlaybackSource (audioStreamer beep.StreamSeekCloser , sampleRate beep.SampleRate , cut * Cut ) (beep.Streamer , float64 , error ) {
241+ initVolume := 0.0
242+ // 没有 cut 代表“播放整段音频”, 这时直接把原始流返回即可。
243+ if cut == nil {
244+ return audioStreamer , initVolume , nil
245+ }
246+
247+ // 结束时间小于等于开始时间时, 语义上就是空区间, 必须明确无声返回。
248+ if cut .EndMS <= cut .StartMS {
249+ return nil , 0 , errEmptyAudioCut
250+ }
251+
252+ // 配置中的裁剪时间单位是毫秒, 这里统一转换为解码后“原始采样率”下的样本索引。
253+ // 注意必须使用原始采样率, 不能使用全局播放采样率, 否则时间轴会错位。
254+ startSample := sampleRate .N (time .Millisecond * time .Duration (cut .StartMS ))
255+ endSample := sampleRate .N (time .Millisecond * time .Duration (cut .EndMS ))
256+ totalSamples := audioStreamer .Len ()
257+
258+ // 没有任何可用样本时, 直接视为空片段。
259+ if totalSamples <= 0 {
260+ return nil , 0 , errEmptyAudioCut
261+ }
262+ // 负值裁剪时间统一钳制到 0, 防止配置异常导致 Seek 到负位置。
263+ if startSample < 0 {
264+ startSample = 0
265+ }
266+ if endSample < 0 {
267+ endSample = 0
268+ }
269+ // 如果起点已经在文件末尾或更后面, 就没有任何可播放内容。
270+ if startSample >= totalSamples {
271+ return nil , 0 , errEmptyAudioCut
272+ }
273+ // 结束位置允许越界, 但要钳制到文件真实长度, 等价于“播放到文件结尾”。
274+ if endSample > totalSamples {
275+ endSample = totalSamples
276+ }
277+ // 钳制后若仍然没有有效区间, 说明最终结果仍是空片段。
278+ if endSample <= startSample {
279+ return nil , 0 , errEmptyAudioCut
280+ }
281+
282+ // Seek 是必须检查错误的。
283+ // 旧逻辑忽略了 Seek 返回值, 一旦解码器拒绝 Seek 或位置异常, 播放可能回退到文件开头,
284+ // 这正是“选中的是静音段, 却听到别处声音”的另一个来源。
285+ if err := audioStreamer .Seek (startSample ); err != nil {
286+ return nil , 0 , fmt .Errorf ("seek start sample %d failed: %w" , startSample , err )
287+ }
288+
289+ initVolume = cut .Volume
290+ // Take 会把源流严格截断为指定样本数。
291+ // 后续即便交给 Resample, Resample 也只能在这个已裁好的窗口中读取数据,
292+ // 不会再接触到区间外的原始样本。
293+ return beep .Take (endSample - startSample , audioStreamer ), initVolume , nil
294+ }
295+
209296// 键音播放器
210297//
211298// Parameters:
@@ -255,29 +342,24 @@ func PlayKeySound(audioFilePath *AudioFilePath, cut *Cut, keycode string, keySta
255342 audioStreamer = JoinManage (audioStreamer )
256343 defer audioStreamer .Close ()
257344
258- fmt .Println ("format.SampleRate" , format .SampleRate )
259- fmt .Println ("formatGlobalSampleRate" , formatGlobalSampleRate )
260-
261- // 将文件的采样率, 设置成与播放器一致
262- reStreamer := beep .Resample (4 , format .SampleRate , formatGlobalSampleRate , audioStreamer )
263-
264- // 处理cut参数
265- endSample := - 1 // 为保证cut=nil时, 也能正常保留原始工作。(当从配置文件获取的信息达不到构造cut时, cut将不会被构造。cut释放为nil的逻辑不应该在播放器端处理<如start和end都等于0时, cut就应该为nil, 即全量PlayKeySound播放>。)
266- initVolume := 0.0
267- // 如果cut=nil则全量播放
268- if cut != nil {
269- startSample := 0
270- startSample = format .SampleRate .N (time .Millisecond * time .Duration (cut .StartMS ))
271- audioStreamer .Seek (startSample )
272- // 若有不合理错误, 则直接退出, 不播放任何声音。
273- // * 如果开始时间等于结束时间, 说明用户不想播放任何声音, 为避免内存浪费, 我们在此处也直接做退出处理。
274- if cut .EndMS <= cut .StartMS {
345+ // 先把“文件 + cut 配置”整理成一个真正可播放的源流。
346+ // 这里的关键原则是: 先裁剪, 再重采样。
347+ // 如果顺序反过来, 重采样器内部的预读行为就可能把裁剪区间外的内容提前读进来。
348+ playbackSource , initVolume , err := preparePlaybackSource (audioStreamer , format .SampleRate , cut )
349+ if err != nil {
350+ // 空片段不记错误日志, 直接静默返回, 这是符合用户配置语义的结果。
351+ if errors .Is (err , errEmptyAudioCut ) {
275352 return
276353 }
277- endSample = format .SampleRate .N (time .Millisecond * time .Duration (cut .EndMS ))
278- initVolume = cut .Volume
354+ // 只有真正的异常才记录日志, 便于后续排查具体音频文件或解码器问题。
355+ logger .Error ("message" , fmt .Sprintf ("error: failed to prepare playback source: %v" , err ))
356+ return
279357 }
280358
359+ // 将文件的采样率, 设置成与播放器一致。
360+ // 裁剪必须先作用在原始采样率的流上, 再交给重采样器, 否则重采样器的预读会把裁剪区间外的数据带入播放。
361+ reStreamer := beep .Resample (4 , format .SampleRate , formatGlobalSampleRate , playbackSource )
362+
281363 // 处理音量
282364 volume := & effects.Volume {
283365 Streamer : reStreamer ,
@@ -309,43 +391,27 @@ func PlayKeySound(audioFilePath *AudioFilePath, cut *Cut, keycode string, keySta
309391 // ctrl := &beep.Ctrl{Streamer: volume, Paused: false}
310392
311393 // 播放音乐
312- done := make (chan struct {})
313- defer close (done )
394+ // 这里使用一个带 1 个缓冲的 done 通道等待播放完成:
395+ // 1. speaker.Play 是异步的, 不会阻塞当前 goroutine;
396+ // 2. beep.Callback 会在整段流真正播放结束后触发;
397+ // 3. 带缓冲是为了防止极端调度下, 回调先于接收方执行时发生阻塞;
398+ // 4. select + default 则保证回调至多投递一次完成信号。
399+ //
400+ // 旧实现之所以复杂, 是因为它依赖“播放途中主动关流”来截断, 所以必须轮询、抢时机、担心回调卡死。
401+ // 现在裁剪已经由 Take 在数据源层面完成, 这里就只需要等待自然播放结束即可。
402+ done := make (chan struct {}, 1 )
314403 // speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
315404 speaker .Play (beep .Seq (volume , beep .Callback (func () {
316- done <- struct {}{}
317- })))
318-
319- // // FIXME: 暂时先如此处理, 后续再进一步处理
320- // go (func() {
321- // time.Sleep(500 * time.Millisecond)
322- // ctrl.Paused = true
323- // done <- true
324- // })()
325-
326- // 等待播放完成
327- re := true
328- for re {
329405 select {
330- case <- done :
331- re = false
332- case <- time .After (10 * time .Millisecond ):
333- pos := audioStreamer .Position ()
334- if pos >= endSample && endSample != - 1 {
335- // speaker.Lock()
336- // ctrl.Paused = true // 目前只能用此一种方式, 在指定时间中止正在播放的音频 (由于暂停后, 会永远的滞留在播放器中等待恢复, 无法进入结束状态而被正确回收, 因此我们暂时采用静音的方式解决问题)
337- // volume.Silent = true // 静音的方式解决问题, 虽然可以保证最终的内存正常释放, 但如果音频文件过大, 仍是会在一定时间内造成不必要的短暂内存泄漏问题。
338- // volume.Silent = true // 仍保留这个的原因是: 为了防止末尾仍有声音, 或者说保证声音的纯净。
339- // audioStreamer.Seek(audioStreamer.Len()) // 直接将其播放进度设置到末尾, 以使其直接播放完毕而自动调用内存回收。(从而避免音频文件过大时, 在一定时间内造成的短暂不必要的内存占用过大问题。)
340- audioStreamer .Close ()
341- // speaker.Unlock()
342- <- done // 为了防止beep.Callback回调卡死而造成的内存泄漏, 这里必须如此处理(就算提前结束, 也要正确的等待Callback回调)
343- re = false
344- }
406+ case done <- struct {}{}:
407+ default :
345408 }
346- }
347- // fmt.Println("播放用时", time.Since(starTime))
348- fmt .Println ("结束------结束------结束" )
409+ })))
410+
411+ // 阻塞到当前片段自然播放完毕。
412+ // 这样可以保证函数返回时, 该次播放对应的生命周期已完整结束,
413+ // 避免调用方误以为播放已经完成而继续触发清理或下一步依赖逻辑。
414+ <- done
349415}
350416
351417func decodeAudioFile (file fs.File , ext string ) (beep.StreamSeekCloser , beep.Format , error ) {
@@ -1223,14 +1289,14 @@ func keySoundParsePlayWith(get ConfigGetter, key_sound_UUID string, keyState str
12231289 }, nil , keycode , keyState )
12241290 return
12251291 }
1226- if vMap ["type" ] == "sounds" {
1227- sound_UUID := vMap ["value" ].(string )
1228- soundParsePlayWith (get , sound_UUID , audioPkgUUID , keycode , keyState )
1292+ if vMap ["type" ] == "sounds" {
1293+ sound_UUID := vMap ["value" ].(string )
1294+ soundParsePlayWith (get , sound_UUID , audioPkgUUID , keycode , keyState )
12291295 return
12301296 }
1231- if vMap ["type" ] == "key_sounds" {
1232- key_sound_UUID := vMap ["value" ].(string )
1233- keySoundParsePlayWith (get , key_sound_UUID , keyState , audioPkgUUID , isGlobal , keycode , count )
1297+ if vMap ["type" ] == "key_sounds" {
1298+ key_sound_UUID := vMap ["value" ].(string )
1299+ keySoundParsePlayWith (get , key_sound_UUID , keyState , audioPkgUUID , isGlobal , keycode , count )
12341300 return
12351301 }
12361302 }
0 commit comments