Compare commits
5 Commits
v1.0.0
...
8d9afb4fc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9afb4fc6 | ||
|
|
0cb60d884c | ||
| a113550aee | |||
|
|
8cacf243d8 | ||
|
|
3ed43bda8b |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -38,4 +38,7 @@ Thumbs.db
|
|||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
|
|
||||||
# Dagger cache
|
# Dagger cache
|
||||||
.dagger/
|
.dagger/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
63
README.md
63
README.md
@@ -7,6 +7,8 @@ A CLI tool for processing WAV files to generate impulse responses (IR) from swee
|
|||||||
- **Fast FFT-based deconvolution** for accurate IR extraction
|
- **Fast FFT-based deconvolution** for accurate IR extraction
|
||||||
- **Automatic input conversion:** Accepts any WAV sample rate, bit depth, or channel count
|
- **Automatic input conversion:** Accepts any WAV sample rate, bit depth, or channel count
|
||||||
- **Optional output IR length:** Specify output IR length in milliseconds with --length-ms
|
- **Optional output IR length:** Specify output IR length in milliseconds with --length-ms
|
||||||
|
- **Optional low-cut and high-cut filtering:** Apply Butterworth filters to the recorded sweep before IR extraction (--lowcut, --highcut, --cut-slope)
|
||||||
|
- **Automatic fade-out:** Linear fade-out at the end of the IR to avoid clicks (default 5 ms, configurable with --fade-ms)
|
||||||
- **96kHz 24-bit WAV file support** for high-quality audio processing
|
- **96kHz 24-bit WAV file support** for high-quality audio processing
|
||||||
- **Multiple output formats** with configurable sample rates and bit depths
|
- **Multiple output formats** with configurable sample rates and bit depths
|
||||||
- **Minimum Phase Transform (MPT)** option for reduced latency IRs
|
- **Minimum Phase Transform (MPT)** option for reduced latency IRs
|
||||||
@@ -57,6 +59,34 @@ Trim or zero-pad the output IR to a specific length (in milliseconds):
|
|||||||
|
|
||||||
This will ensure the output IR is exactly 100 ms long (trimming or zero-padding as needed).
|
This will ensure the output IR is exactly 100 ms long (trimming or zero-padding as needed).
|
||||||
|
|
||||||
|
### Fade-Out to Avoid Clicks
|
||||||
|
|
||||||
|
By default, a 5 ms linear fade-out is applied to the end of the IR to avoid clicks. You can change the fade duration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav --fade-ms 10
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies a 10 ms fade-out at the end of the IR.
|
||||||
|
|
||||||
|
### Filtering the Recorded Sweep
|
||||||
|
|
||||||
|
You can apply a low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction. This is useful for removing rumble, DC, or high-frequency noise:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav --lowcut 40 --highcut 18000
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies a 40 Hz low-cut (high-pass) and 18 kHz high-cut (low-pass) filter to the recorded sweep.
|
||||||
|
|
||||||
|
You can control the filter steepness (slope) with `--cut-slope` (in dB/octave, default 12). For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./valhallir-deconvolver --sweep sweep.wav --recorded recorded.wav --output ir.wav --lowcut 40 --highcut 18000 --cut-slope 24
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies a 40 Hz low-cut and 18 kHz high-cut, both with a 24 dB/octave slope (steeper than the default 12).
|
||||||
|
|
||||||
### Different Output Formats
|
### Different Output Formats
|
||||||
|
|
||||||
Generate IRs in different sample rates and bit depths:
|
Generate IRs in different sample rates and bit depths:
|
||||||
@@ -116,6 +146,10 @@ Generate IRs in different sample rates and bit depths:
|
|||||||
| `--normalize` | Normalize output to peak value (0.0-1.0) | 0.95 | No |
|
| `--normalize` | Normalize output to peak value (0.0-1.0) | 0.95 | No |
|
||||||
| `--trim-threshold` | Silence threshold for trimming (0.0-1.0) | 0.001 | No |
|
| `--trim-threshold` | Silence threshold for trimming (0.0-1.0) | 0.001 | No |
|
||||||
| `--length-ms` | Output IR length in milliseconds (trim or zero-pad) | - | No |
|
| `--length-ms` | Output IR length in milliseconds (trim or zero-pad) | - | No |
|
||||||
|
| `--fade-ms` | Fade-out duration in milliseconds at end of IR (default 5) | 5 | No |
|
||||||
|
| `--lowcut` | Low-cut filter (high-pass) cutoff frequency in Hz (recorded sweep) | - | No |
|
||||||
|
| `--highcut` | High-cut filter (low-pass) cutoff frequency in Hz (recorded sweep) | - | No |
|
||||||
|
| `--cut-slope` | Filter slope in dB/octave (12, 24, 36, ...; default 12) | 12 | No |
|
||||||
|
|
||||||
## File Requirements
|
## File Requirements
|
||||||
|
|
||||||
@@ -143,6 +177,15 @@ Generate IRs in different sample rates and bit depths:
|
|||||||
- If `--length-ms` is set, the output IR (and MPT IR) will be trimmed or zero-padded to the specified length in milliseconds
|
- If `--length-ms` is set, the output IR (and MPT IR) will be trimmed or zero-padded to the specified length in milliseconds
|
||||||
- If not set, the full IR is used
|
- If not set, the full IR is used
|
||||||
|
|
||||||
|
### Fade-Out
|
||||||
|
- By default, a 5 ms linear fade-out is applied to the end of the IR (and MPT IR) to avoid clicks
|
||||||
|
- You can change the fade duration with `--fade-ms`
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
- You can apply a Butterworth low-cut (high-pass) and/or high-cut (low-pass) filter to the recorded sweep before IR extraction
|
||||||
|
- Use `--lowcut` and/or `--highcut` to specify cutoff frequencies in Hz
|
||||||
|
- Use `--cut-slope` to control the filter steepness (12 dB/octave = gentle, 24+ = steeper)
|
||||||
|
|
||||||
### Deconvolution Process
|
### Deconvolution Process
|
||||||
1. **FFT-based deconvolution** of recorded signal by sweep signal
|
1. **FFT-based deconvolution** of recorded signal by sweep signal
|
||||||
2. **Regularization** to prevent division by zero
|
2. **Regularization** to prevent division by zero
|
||||||
@@ -218,19 +261,35 @@ The pipeline is defined in [`ci/dagger.go`](./ci/dagger.go). It outputs binaries
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
1. Install the Dagger Go SDK:
|
1. Install the Dagger Go SDK and dependencies:
|
||||||
```sh
|
```sh
|
||||||
go install dagger.io/dagger@latest
|
go install dagger.io/dagger@latest
|
||||||
|
go get github.com/joho/godotenv
|
||||||
go mod tidy
|
go mod tidy
|
||||||
```
|
```
|
||||||
2. Run the pipeline:
|
2. Build for all platforms:
|
||||||
```sh
|
```sh
|
||||||
go run ci/dagger.go
|
go run ci/dagger.go
|
||||||
```
|
```
|
||||||
|
3. (Optional) Upload binaries to a Gitea release:
|
||||||
|
- Create a `.env` file in the project root with:
|
||||||
|
```
|
||||||
|
GITEA_TOKEN=your_token
|
||||||
|
GITEA_URL=https://your.gitea.server
|
||||||
|
GITEA_OWNER=youruser
|
||||||
|
GITEA_REPO=yourrepo
|
||||||
|
```
|
||||||
|
- Run:
|
||||||
|
```sh
|
||||||
|
go run ci/dagger.go --release v1.0.0
|
||||||
|
```
|
||||||
|
- This will create (if needed) and upload all binaries to the specified release tag on your Gitea instance.
|
||||||
|
- **The pipeline will also create and push a local git tag for the release if it does not already exist.**
|
||||||
|
|
||||||
### Troubleshooting Dagger
|
### Troubleshooting Dagger
|
||||||
- If you see `could not import dagger.io/dagger`, make sure you have installed the Dagger Go SDK and run `go mod tidy`.
|
- If you see `could not import dagger.io/dagger`, make sure you have installed the Dagger Go SDK and run `go mod tidy`.
|
||||||
- The pipeline requires Docker or a compatible container runtime.
|
- The pipeline requires Docker or a compatible container runtime.
|
||||||
|
- For Gitea upload, ensure your `.env` file is present and correct.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
4
ci/.env.example
Normal file
4
ci/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
GITEA_TOKEN=your_token
|
||||||
|
GITEA_URL=https://your.gitea.server
|
||||||
|
GITEA_OWNER=youruser
|
||||||
|
GITEA_REPO=yourrepo
|
||||||
164
ci/dagger.go
164
ci/dagger.go
@@ -1,16 +1,35 @@
|
|||||||
// Dagger pipeline for multi-platform builds.
|
// Dagger pipeline for multi-platform builds.
|
||||||
// Requires: go install dagger.io/dagger@latest && go mod tidy
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ci/dagger.go [--release v1.0.0]
|
||||||
|
//
|
||||||
|
// If --release is provided, uploads all built binaries in dist/ to the specified Gitea release.
|
||||||
|
// Requires .env file with GITEA_TOKEN, GITEA_URL, GITEA_OWNER, GITEA_REPO.
|
||||||
|
// Requires: go install dagger.io/dagger@latest && go get github.com/joho/godotenv && go mod tidy
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"dagger.io/dagger"
|
"dagger.io/dagger"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
releaseTag := flag.String("release", "", "(optional) Release tag to upload binaries to Gitea")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
|
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,6 +49,8 @@ func main() {
|
|||||||
|
|
||||||
src := client.Host().Directory(".")
|
src := client.Host().Directory(".")
|
||||||
|
|
||||||
|
var builtBinaries []string
|
||||||
|
|
||||||
for _, p := range platforms {
|
for _, p := range platforms {
|
||||||
binName := fmt.Sprintf("valhallir-deconvolver-%s-%s", p.OS, p.Arch)
|
binName := fmt.Sprintf("valhallir-deconvolver-%s-%s", p.OS, p.Arch)
|
||||||
if p.OS == "windows" {
|
if p.OS == "windows" {
|
||||||
@@ -50,5 +71,146 @@ func main() {
|
|||||||
panic(fmt.Sprintf("export failed for %s/%s: %v", p.OS, p.Arch, err))
|
panic(fmt.Sprintf("export failed for %s/%s: %v", p.OS, p.Arch, err))
|
||||||
}
|
}
|
||||||
fmt.Printf("Built and exported %s\n", outPath)
|
fmt.Printf("Built and exported %s\n", outPath)
|
||||||
|
builtBinaries = append(builtBinaries, outPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *releaseTag != "" {
|
||||||
|
fmt.Printf("\nUploading binaries to Gitea release: %s\n", *releaseTag)
|
||||||
|
// Load .env
|
||||||
|
err := godotenv.Load("ci/.env")
|
||||||
|
if err != nil {
|
||||||
|
panic("Error loading .env file: " + err.Error())
|
||||||
|
}
|
||||||
|
giteaToken := os.Getenv("GITEA_TOKEN")
|
||||||
|
giteaURL := os.Getenv("GITEA_URL")
|
||||||
|
giteaOwner := os.Getenv("GITEA_OWNER")
|
||||||
|
giteaRepo := os.Getenv("GITEA_REPO")
|
||||||
|
if giteaToken == "" || giteaURL == "" || giteaOwner == "" || giteaRepo == "" {
|
||||||
|
panic("GITEA_TOKEN, GITEA_URL, GITEA_OWNER, GITEA_REPO must be set in .env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get or create the release
|
||||||
|
releaseID, err := getOrCreateRelease(giteaURL, giteaOwner, giteaRepo, *releaseTag, giteaToken)
|
||||||
|
if err != nil {
|
||||||
|
panic("Failed to get or create release: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Upload each binary as an asset
|
||||||
|
for _, bin := range builtBinaries {
|
||||||
|
fmt.Printf("Uploading %s...\n", bin)
|
||||||
|
err := uploadAsset(giteaURL, giteaOwner, giteaRepo, releaseID, bin, giteaToken)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to upload %s: %v", bin, err))
|
||||||
|
}
|
||||||
|
fmt.Printf("Uploaded %s\n", bin)
|
||||||
|
}
|
||||||
|
fmt.Println("All binaries uploaded to Gitea release.")
|
||||||
|
|
||||||
|
// 3. Tag the release locally and push the tag
|
||||||
|
if err := tagAndPush(*releaseTag); err != nil {
|
||||||
|
panic("Failed to tag and push: " + err.Error())
|
||||||
|
}
|
||||||
|
fmt.Printf("Tagged and pushed %s\n", *releaseTag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOrCreateRelease(url, owner, repo, tag, token string) (int, error) {
|
||||||
|
// Try to get the release by tag
|
||||||
|
api := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(url, "/"), owner, repo, tag)
|
||||||
|
req, _ := http.NewRequest("GET", api, nil)
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
// Parse JSON to get ID
|
||||||
|
type releaseResp struct{ ID int }
|
||||||
|
var r releaseResp
|
||||||
|
io.ReadAll(resp.Body) // ignore body for now, parse below
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
dec.Decode(&r)
|
||||||
|
return r.ID, nil
|
||||||
|
}
|
||||||
|
// If not found, create it
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
api = fmt.Sprintf("%s/api/v1/repos/%s/%s/releases", strings.TrimRight(url, "/"), owner, repo)
|
||||||
|
body := strings.NewReader(fmt.Sprintf(`{"tag_name":"%s","name":"%s"}`, tag, tag))
|
||||||
|
req, _ = http.NewRequest("POST", api, body)
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 201 {
|
||||||
|
type releaseResp struct{ ID int }
|
||||||
|
var r releaseResp
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
dec.Decode(&r)
|
||||||
|
return r.ID, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("failed to create release: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("failed to get release: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadAsset(url, owner, repo string, releaseID int, filePath, token string) error {
|
||||||
|
api := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%d/assets?name=%s", strings.TrimRight(url, "/"), owner, repo, releaseID, filepath.Base(filePath))
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&b)
|
||||||
|
f, err := w.CreateFormFile("attachment", filepath.Base(filePath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", api, &b)
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 201 {
|
||||||
|
return fmt.Errorf("upload failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagAndPush(tag string) error {
|
||||||
|
// Check if tag exists
|
||||||
|
cmd := exec.Command("git", "tag", "--list", tag)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(out)) == tag {
|
||||||
|
// Tag already exists
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Create tag
|
||||||
|
cmd = exec.Command("git", "tag", tag)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Push tag
|
||||||
|
cmd = exec.Command("git", "push", "origin", tag)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -1,11 +1,12 @@
|
|||||||
module valhallir-deconvolver
|
module valhallir-deconvolver
|
||||||
|
|
||||||
go 1.24.1
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dagger.io/dagger v0.18.12
|
dagger.io/dagger v0.18.12
|
||||||
github.com/go-audio/audio v1.0.0
|
github.com/go-audio/audio v1.0.0
|
||||||
github.com/go-audio/wav v1.1.0
|
github.com/go-audio/wav v1.1.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12
|
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
gonum.org/v1/gonum v0.13.0
|
gonum.org/v1/gonum v0.13.0
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -35,6 +35,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 h1:dd7vnTDfjtwCETZDrRe+GPYNLA1jBtbZeyfyE8eZCyk=
|
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 h1:dd7vnTDfjtwCETZDrRe+GPYNLA1jBtbZeyfyE8eZCyk=
|
||||||
|
|||||||
52
main.go
52
main.go
@@ -60,6 +60,24 @@ func main() {
|
|||||||
Name: "length-ms",
|
Name: "length-ms",
|
||||||
Usage: "Optional: Output IR length in milliseconds (will trim or zero-pad as needed)",
|
Usage: "Optional: Output IR length in milliseconds (will trim or zero-pad as needed)",
|
||||||
},
|
},
|
||||||
|
&cli.Float64Flag{
|
||||||
|
Name: "fade-ms",
|
||||||
|
Usage: "Fade-out duration in milliseconds to apply at the end of the IR (default 5)",
|
||||||
|
Value: 5.0,
|
||||||
|
},
|
||||||
|
&cli.Float64Flag{
|
||||||
|
Name: "highcut",
|
||||||
|
Usage: "High-cut filter (low-pass) cutoff frequency in Hz (applied to recorded sweep, optional)",
|
||||||
|
},
|
||||||
|
&cli.Float64Flag{
|
||||||
|
Name: "lowcut",
|
||||||
|
Usage: "Low-cut filter (high-pass) cutoff frequency in Hz (applied to recorded sweep, optional)",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "cut-slope",
|
||||||
|
Usage: "Cut filter slope in dB/octave (12, 24, 36, 48, ...; default 12)",
|
||||||
|
Value: 12,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
// Read sweep WAV file
|
// Read sweep WAV file
|
||||||
@@ -77,8 +95,26 @@ func main() {
|
|||||||
log.Printf("Sweep: %d samples, %d channels", len(sweepData.PCMData), sweepData.Channels)
|
log.Printf("Sweep: %d samples, %d channels", len(sweepData.PCMData), sweepData.Channels)
|
||||||
log.Printf("Recorded: %d samples, %d channels", len(recordedData.PCMData), recordedData.Channels)
|
log.Printf("Recorded: %d samples, %d channels", len(recordedData.PCMData), recordedData.Channels)
|
||||||
|
|
||||||
|
// Optionally filter the recorded sweep
|
||||||
|
recordedFiltered := recordedData.PCMData
|
||||||
|
recSampleRate := recordedData.SampleRate
|
||||||
|
highcutHz := c.Float64("highcut")
|
||||||
|
lowcutHz := c.Float64("lowcut")
|
||||||
|
cutSlope := c.Int("cut-slope")
|
||||||
|
if cutSlope < 12 || cutSlope%12 != 0 {
|
||||||
|
return fmt.Errorf("cut-slope must be a positive multiple of 12 (got %d)", cutSlope)
|
||||||
|
}
|
||||||
|
if lowcutHz > 0 {
|
||||||
|
log.Printf("Applying low-cut (high-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", lowcutHz, cutSlope)
|
||||||
|
recordedFiltered = convolve.CascadeLowcut(recordedFiltered, recSampleRate, lowcutHz, cutSlope)
|
||||||
|
}
|
||||||
|
if highcutHz > 0 {
|
||||||
|
log.Printf("Applying high-cut (low-pass) filter to recorded sweep: %.2f Hz, slope: %d dB/oct", highcutHz, cutSlope)
|
||||||
|
recordedFiltered = convolve.CascadeHighcut(recordedFiltered, recSampleRate, highcutHz, cutSlope)
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Performing deconvolution...")
|
log.Println("Performing deconvolution...")
|
||||||
ir := convolve.Deconvolve(sweepData.PCMData, recordedData.PCMData)
|
ir := convolve.Deconvolve(sweepData.PCMData, recordedFiltered)
|
||||||
log.Printf("Deconvolution result: %d samples", len(ir))
|
log.Printf("Deconvolution result: %d samples", len(ir))
|
||||||
|
|
||||||
log.Println("Trimming silence...")
|
log.Println("Trimming silence...")
|
||||||
@@ -135,6 +171,14 @@ func main() {
|
|||||||
ir = convolve.TrimOrPad(ir, targetSamples)
|
ir = convolve.TrimOrPad(ir, targetSamples)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply fade-out
|
||||||
|
fadeMs := c.Float64("fade-ms")
|
||||||
|
fadeSamples := int(float64(targetSampleRate) * fadeMs / 1000.0)
|
||||||
|
if fadeSamples > 0 {
|
||||||
|
log.Printf("Applying linear fade-out: %d samples (%.2f ms)...", fadeSamples, fadeMs)
|
||||||
|
ir = convolve.FadeOutLinear(ir, fadeSamples)
|
||||||
|
}
|
||||||
|
|
||||||
// Write regular IR
|
// Write regular IR
|
||||||
log.Printf("Writing IR to: %s (%dHz, %d-bit WAV)", c.String("output"), sampleRate, bitDepth)
|
log.Printf("Writing IR to: %s (%dHz, %d-bit WAV)", c.String("output"), sampleRate, bitDepth)
|
||||||
if err := wav.WriteWAVFileWithOptions(c.String("output"), ir, sampleRate, bitDepth); err != nil {
|
if err := wav.WriteWAVFileWithOptions(c.String("output"), ir, sampleRate, bitDepth); err != nil {
|
||||||
@@ -165,6 +209,12 @@ func main() {
|
|||||||
mptIR = convolve.TrimOrPad(mptIR, targetSamples)
|
mptIR = convolve.TrimOrPad(mptIR, targetSamples)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply fade-out to MPT IR
|
||||||
|
if fadeSamples > 0 {
|
||||||
|
log.Printf("Applying linear fade-out to MPT IR: %d samples (%.2f ms)...", fadeSamples, fadeMs)
|
||||||
|
mptIR = convolve.FadeOutLinear(mptIR, fadeSamples)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate MPT output filename
|
// Generate MPT output filename
|
||||||
outputPath := c.String("output")
|
outputPath := c.String("output")
|
||||||
if len(outputPath) > 4 && outputPath[len(outputPath)-4:] == ".wav" {
|
if len(outputPath) > 4 && outputPath[len(outputPath)-4:] == ".wav" {
|
||||||
|
|||||||
@@ -322,3 +322,134 @@ func Resample(data []float64, fromSampleRate, toSampleRate int) []float64 {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FadeOutLinear applies a linear fade-out to the last fadeSamples of the data.
|
||||||
|
// fadeSamples is the number of samples over which to fade to zero.
|
||||||
|
func FadeOutLinear(data []float64, fadeSamples int) []float64 {
|
||||||
|
if fadeSamples <= 0 || len(data) == 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if fadeSamples > len(data) {
|
||||||
|
fadeSamples = len(data)
|
||||||
|
}
|
||||||
|
out := make([]float64, len(data))
|
||||||
|
copy(out, data)
|
||||||
|
start := len(data) - fadeSamples
|
||||||
|
for i := start; i < len(data); i++ {
|
||||||
|
fade := float64(len(data)-i) / float64(fadeSamples)
|
||||||
|
out[i] *= fade
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyLowpassButterworth applies a 2nd-order Butterworth low-pass filter to the data.
|
||||||
|
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
|
||||||
|
func ApplyLowpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
|
||||||
|
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
// Biquad coefficients
|
||||||
|
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
|
||||||
|
cosw0 := math.Cos(w0)
|
||||||
|
sinw0 := math.Sin(w0)
|
||||||
|
Q := 1.0 / math.Sqrt(2) // Butterworth Q
|
||||||
|
alpha := sinw0 / (2 * Q)
|
||||||
|
|
||||||
|
b0 := (1 - cosw0) / 2
|
||||||
|
b1 := 1 - cosw0
|
||||||
|
b2 := (1 - cosw0) / 2
|
||||||
|
a0 := 1 + alpha
|
||||||
|
a1 := -2 * cosw0
|
||||||
|
a2 := 1 - alpha
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
b0 /= a0
|
||||||
|
b1 /= a0
|
||||||
|
b2 /= a0
|
||||||
|
a1 /= a0
|
||||||
|
a2 /= a0
|
||||||
|
|
||||||
|
// Apply filter (Direct Form I)
|
||||||
|
out := make([]float64, len(data))
|
||||||
|
var x1, x2, y1, y2 float64
|
||||||
|
for i := 0; i < len(data); i++ {
|
||||||
|
x0 := data[i]
|
||||||
|
y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2
|
||||||
|
out[i] = y0
|
||||||
|
x2 = x1
|
||||||
|
x1 = x0
|
||||||
|
y2 = y1
|
||||||
|
y1 = y0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyHighpassButterworth applies a 2nd-order Butterworth high-pass filter to the data.
|
||||||
|
// cutoffHz: cutoff frequency in Hz, sampleRate: sample rate in Hz.
|
||||||
|
func ApplyHighpassButterworth(data []float64, sampleRate int, cutoffHz float64) []float64 {
|
||||||
|
if cutoffHz <= 0 || cutoffHz >= float64(sampleRate)/2 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
// Biquad coefficients
|
||||||
|
w0 := 2 * math.Pi * cutoffHz / float64(sampleRate)
|
||||||
|
cosw0 := math.Cos(w0)
|
||||||
|
sinw0 := math.Sin(w0)
|
||||||
|
Q := 1.0 / math.Sqrt(2) // Butterworth Q
|
||||||
|
alpha := sinw0 / (2 * Q)
|
||||||
|
|
||||||
|
b0 := (1 + cosw0) / 2
|
||||||
|
b1 := -(1 + cosw0)
|
||||||
|
b2 := (1 + cosw0) / 2
|
||||||
|
a0 := 1 + alpha
|
||||||
|
a1 := -2 * cosw0
|
||||||
|
a2 := 1 - alpha
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
b0 /= a0
|
||||||
|
b1 /= a0
|
||||||
|
b2 /= a0
|
||||||
|
a1 /= a0
|
||||||
|
a2 /= a0
|
||||||
|
|
||||||
|
// Apply filter (Direct Form I)
|
||||||
|
out := make([]float64, len(data))
|
||||||
|
var x1, x2, y1, y2 float64
|
||||||
|
for i := 0; i < len(data); i++ {
|
||||||
|
x0 := data[i]
|
||||||
|
y0 := b0*x0 + b1*x1 + b2*x2 - a1*y1 - a2*y2
|
||||||
|
out[i] = y0
|
||||||
|
x2 = x1
|
||||||
|
x1 = x0
|
||||||
|
y2 = y1
|
||||||
|
y1 = y0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadeLowcut applies the low-cut (high-pass) filter multiple times for steeper slopes.
|
||||||
|
// slopeDb: 12, 24, 36, ... (dB/octave)
|
||||||
|
func CascadeLowcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 {
|
||||||
|
if slopeDb < 12 {
|
||||||
|
slopeDb = 12
|
||||||
|
}
|
||||||
|
n := slopeDb / 12
|
||||||
|
out := data
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = ApplyHighpassButterworth(out, sampleRate, cutoffHz)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadeHighcut applies the high-cut (low-pass) filter multiple times for steeper slopes.
|
||||||
|
// slopeDb: 12, 24, 36, ... (dB/octave)
|
||||||
|
func CascadeHighcut(data []float64, sampleRate int, cutoffHz float64, slopeDb int) []float64 {
|
||||||
|
if slopeDb < 12 {
|
||||||
|
slopeDb = 12
|
||||||
|
}
|
||||||
|
n := slopeDb / 12
|
||||||
|
out := data
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = ApplyLowpassButterworth(out, sampleRate, cutoffHz)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
BIN
testdata/ir.wav
vendored
Normal file
BIN
testdata/ir.wav
vendored
Normal file
Binary file not shown.
BIN
testdata/ir_mpt.wav
vendored
Normal file
BIN
testdata/ir_mpt.wav
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user