136 lines
4.6 KiB
Go
136 lines
4.6 KiB
Go
// Fenrir console screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package sound
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
)
|
|
|
|
// Fenrir's cues are short PCM WAV files. Validate RIFF chunk bounds before
|
|
// decoding, then resample once to the persistent stream's stereo rate.
|
|
func decode_wav(reader io.Reader) (*clip, error) {
|
|
data, err := io.ReadAll(io.LimitReader(reader, (4<<20)+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > 4<<20 {
|
|
return nil, fmt.Errorf("WAV exceeds 4 MiB")
|
|
}
|
|
if len(data) < 12 || string(data[:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
|
return nil, fmt.Errorf("not a RIFF WAV file")
|
|
}
|
|
end := uint64(binary.LittleEndian.Uint32(data[4:8])) + 8
|
|
if end < 12 || end > uint64(len(data)) {
|
|
return nil, fmt.Errorf("truncated WAV container")
|
|
}
|
|
var format, audio []byte
|
|
for offset := uint64(12); offset < end; {
|
|
if end-offset < 8 {
|
|
return nil, fmt.Errorf("truncated WAV chunk header")
|
|
}
|
|
name := string(data[offset : offset+4])
|
|
size := uint64(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
|
|
offset += 8
|
|
if size > end-offset {
|
|
return nil, fmt.Errorf("truncated WAV %s chunk", name)
|
|
}
|
|
switch name {
|
|
case "fmt ":
|
|
if format != nil {
|
|
return nil, fmt.Errorf("duplicate WAV format")
|
|
}
|
|
format = data[offset : offset+size]
|
|
case "data":
|
|
if audio != nil {
|
|
return nil, fmt.Errorf("duplicate WAV data")
|
|
}
|
|
audio = data[offset : offset+size]
|
|
}
|
|
offset += size + size%2
|
|
if offset > end {
|
|
return nil, fmt.Errorf("missing WAV padding")
|
|
}
|
|
}
|
|
if len(format) < 16 || len(audio) == 0 {
|
|
return nil, fmt.Errorf("WAV needs format and audio data")
|
|
}
|
|
encoding := binary.LittleEndian.Uint16(format)
|
|
channels := int(binary.LittleEndian.Uint16(format[2:]))
|
|
rate := int(binary.LittleEndian.Uint32(format[4:]))
|
|
align := int(binary.LittleEndian.Uint16(format[12:]))
|
|
bits := int(binary.LittleEndian.Uint16(format[14:]))
|
|
if encoding == 0xfffe {
|
|
// WAVEFORMATEXTENSIBLE stores PCM's subtype and valid-bit count in
|
|
// the extended header. Fenrir's installed default pack uses this.
|
|
pcmGUID := []byte{1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71}
|
|
if len(format) < 40 || binary.LittleEndian.Uint16(format[16:]) < 22 || !bytes.Equal(format[24:40], pcmGUID) {
|
|
return nil, fmt.Errorf("unsupported extended WAV format")
|
|
}
|
|
validBits := int(binary.LittleEndian.Uint16(format[18:]))
|
|
if validBits < 1 || validBits > bits {
|
|
return nil, fmt.Errorf("invalid WAV valid-bit count")
|
|
}
|
|
encoding = 1
|
|
}
|
|
if encoding != 1 || channels < 1 || channels > 2 || rate < 8000 || rate > 192000 || (bits != 8 && bits != 16 && bits != 24 && bits != 32) {
|
|
return nil, fmt.Errorf("unsupported PCM WAV format")
|
|
}
|
|
bytesPerSample := bits / 8
|
|
if align != channels*bytesPerSample || len(audio)%align != 0 {
|
|
return nil, fmt.Errorf("invalid WAV frame alignment")
|
|
}
|
|
frames := len(audio) / align
|
|
if frames > rate*int(maxDuration.Seconds()) {
|
|
return nil, fmt.Errorf("sound exceeds ten seconds")
|
|
}
|
|
readSample := func(frame, channel int) float32 {
|
|
if channels == 1 {
|
|
channel = 0
|
|
}
|
|
value := audio[frame*align+channel*bytesPerSample:]
|
|
switch bits {
|
|
case 8:
|
|
return (float32(value[0]) - 128) / 128
|
|
case 16:
|
|
return float32(int16(binary.LittleEndian.Uint16(value))) / 32768
|
|
case 24:
|
|
signed := int32(value[0]) | int32(value[1])<<8 | int32(value[2])<<16
|
|
return float32(signed<<8>>8) / 8388608
|
|
default:
|
|
return float32(int32(binary.LittleEndian.Uint32(value))) / 2147483648
|
|
}
|
|
}
|
|
count := int((int64(frames)*sampleRate + int64(rate) - 1) / int64(rate))
|
|
samples := make([]float32, count*2)
|
|
for frame := range count {
|
|
position := float64(frame) * float64(rate) / sampleRate
|
|
left := min(int(position), frames-1)
|
|
right := min(left+1, frames-1)
|
|
fraction := float32(position - math.Floor(position))
|
|
for channel := range 2 {
|
|
first, second := readSample(left, channel), readSample(right, channel)
|
|
samples[frame*2+channel] = first + (second-first)*fraction
|
|
}
|
|
}
|
|
return &clip{samples: samples}, nil
|
|
}
|