2IURSWW3ZXRBH3DPJO437YE2FGMG54MPFL6W6UCEF5HODPVWVVTQC P3RVXIVXHD4W5KQ2FUU3ZF3756ZN6YJ2G57EV7CDCEPKTXTNCSXAC 2C4FPBSQTF4FM4J45HZGWB6L3E56U7M26TLYIRUMXC6SULR6XSQAC IFVRAERTCCDICNTYTG3TX2WASB6RXQQEJWWXQMQZJSQDQ3HLE5OQC SDBVLSDDRPQF62XXKJKM2RQLMXOKKHOYRVUF6DIUDFRYCGL2DW3QC DBOROCRFD6A5SJBMFYFEJI5S5M77X4EFEK6KDQWA5QDMQJKIHRWQC U6JEEU5O477ZOJ5UMRMOJSGPEJEU6Q7KMPKUSDF56CYVUJWL7QBQC EW7VBNMGWFBC73ZUDLB4LIK2HWFKA74ZUTUDG4J575ZQHEFHW4UQC W3A2EECCD23SVHJZN6MXPH2PAVFHH5CNFD2XHPQRRW6M4GUTG3FAC package utilsimport ("math""testing")func TestResample(t *testing.T) {t.Run("should return same samples for speed 1.0", func(t *testing.T) {samples := []float64{0.1, 0.2, 0.3, 0.4, 0.5}result := Resample(samples, 1.0)if len(result) != len(samples) {t.Errorf("length mismatch: got %d, want %d", len(result), len(samples))}for i := range samples {if result[i] != samples[i] {t.Errorf("sample %d mismatch: got %f, want %f", i, result[i], samples[i])}}})t.Run("should double samples for half speed", func(t *testing.T) {samples := []float64{0.0, 1.0, 0.0, -1.0, 0.0}result := Resample(samples, 0.5)// Half speed = 2x more samplesexpectedLen := len(samples) * 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should halve samples for double speed", func(t *testing.T) {samples := []float64{0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5, 0.0}result := Resample(samples, 2.0)// Double speed = half the samplesexpectedLen := len(samples) / 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should use linear interpolation", func(t *testing.T) {// With samples [0, 1], half-speed should interpolate to [0, 0.5, 1]samples := []float64{0.0, 1.0}result := Resample(samples, 0.5)// Expected: 4 samples (2 / 0.5 = 4)if len(result) != 4 {t.Errorf("length mismatch: got %d, want 4", len(result))}// Check interpolation: index 1 should be ~0.5 (midpoint)expected := 0.5if math.Abs(result[1]-expected) > 0.01 {t.Errorf("interpolated value mismatch: got %f, want ~%f", result[1], expected)}})t.Run("should handle empty samples", func(t *testing.T) {result := Resample([]float64{}, 0.5)if len(result) != 0 {t.Errorf("expected empty result, got %d samples", len(result))}})t.Run("should handle single sample", func(t *testing.T) {samples := []float64{0.5}result := Resample(samples, 0.5)// 1 / 0.5 = 2 samplesif len(result) != 2 {t.Errorf("length mismatch: got %d, want 2", len(result))}})}func TestResampleQuality(t *testing.T) {t.Run("should preserve zero crossings", func(t *testing.T) {// Sine wave: should have zero crossings at multiples of pisampleRate := 1000samples := make([]float64, sampleRate)for i := range samples {samples[i] = math.Sin(2 * math.Pi * float64(i) / float64(sampleRate))}// Resample to half speedresult := Resample(samples, 0.5)// First sample should still be ~0 (sine at 0)if math.Abs(result[0]) > 0.01 {t.Errorf("first sample not near zero: got %f", result[0])}// Peak should still be ~1.0 (sine max)peakFound := falsefor _, s := range result {if math.Abs(s-1.0) < 0.1 {peakFound = truebreak}}if !peakFound {t.Error("peak not preserved in resampled signal")}})}
package utils// Resample changes playback speed using linear interpolation.// speed > 1.0 = faster (fewer samples), speed < 1.0 = slower (more samples).// For half-speed playback, use speed=0.5 which doubles the sample count.func Resample(samples []float64, speed float64) []float64 {if speed == 1.0 || len(samples) == 0 {return samples}// Calculate new length: slower speed = more samplesnewLen := int(float64(len(samples)) / speed)if newLen <= 0 {return samples}result := make([]float64, newLen)for i := 0; i < newLen; i++ {// Source index in original samples (floating point)srcIdx := float64(i) * speedidx0 := int(srcIdx)idx1 := idx0 + 1// Clamp to valid rangeif idx0 >= len(samples) {idx0 = len(samples) - 1}if idx1 >= len(samples) {idx1 = len(samples) - 1}// Linear interpolation between adjacent samplesfrac := srcIdx - float64(idx0)result[i] = samples[idx0]*(1-frac) + samples[idx1]*frac}return result}
ap.PlayAtSpeed(samples, sampleRate, 1.0)}// PlayAtSpeed plays samples at the given speed (1.0 = normal, 0.5 = half speed).// Speed change is achieved by resampling the audio.// Playback is non-blocking — audio plays in the background.func (ap *AudioPlayer) PlayAtSpeed(samples []float64, sampleRate int, speed float64) {
key := msg.Key()// Handle Enter key (check code, not string, to catch modifiers)if key.Code == tea.KeyEnter {speed := 1.0if key.Mod&tea.ModShift != 0 {speed = 0.5}if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {m.err = errMsg}return m, waitForPlaybackFinished(m.state)}
Config ClassifyConfigDataFiles []*utils.DataFileFileIdx intSegmentIdx intDirty boolPlayer *utils.AudioPlayer
Config ClassifyConfigDataFiles []*utils.DataFileFileIdx intSegmentIdx intDirty boolPlayer *utils.AudioPlayerPlaybackSpeed float64 // Current playback speed (1.0 = normal, 0.5 = half speed)
## [2026-03-04] Half-Speed Audio Playback in Classify TUI**New feature:** Press Shift+Enter in the classify TUI to play audio at half speed.**Changes:**- `utils/resample.go` — **NEW** Linear interpolation resampling for speed changes- `utils/audio_player.go` — Added `PlayAtSpeed(samples, sampleRate, speed)` method- `tools/calls_classify.go` — Added `PlaybackSpeed` field to `ClassifyState`- `tui/classify.go` — Detect Shift+Enter modifier, display "▶ Playing 0.5x..." in status