Compare commits

..

No commits in common. "main" and "1.0.x" have entirely different histories.
main ... 1.0.x

46 changed files with 287 additions and 4406 deletions

3
.gitignore vendored
View File

@ -1,5 +1,4 @@
build/
dist/
build_android/
build_ios/
build_macos/
@ -35,5 +34,3 @@ android/app/src/main/res/mipmap-anydpi-v26/
android/app/src/main/jniLibs/
*.xcuserstate
xcuserdata/
web/dist/

View File

@ -1,5 +1,5 @@
[workspace]
members = [".", "xtask", "web"]
members = [".", "xtask"]
default-members = ["."]
resolver = "2"

View File

@ -1,13 +0,0 @@
# pre-build steps installing libasound2-dev for the target architecture.
[target.x86_64-unknown-linux-gnu]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get --assume-yes install libasound2-dev:$CROSS_DEB_ARCH",
]
[target.aarch64-unknown-linux-gnu]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get --assume-yes install libasound2-dev:$CROSS_DEB_ARCH",
]

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
<path d="M 104.059 38.078 C 111.987 35.523 118.439 74.492 114.356 76.505 C 112.783 82.431 110.279 87.73 107.071 92.34 L 79.027 66.024 C 79.284 64.55 79.284 63.042 79.027 61.568 L 104.059 38.078 Z M 107.071 92.34 L 94.789 104.59 C 78.285 116.044 55.379 117.109 37.679 104.646 L 25.432 92.357 C 27.078 94.721 28.941 96.96 31.014 99.042 C 33.129 101.147 35.359 103.013 37.679 104.646 L 46.606 95.133 L 62.617 106.115 L 62.617 78.071 L 64.032 76.563 C 65.506 76.82 67.014 76.82 68.488 76.563 L 94.789 104.59 C 99.492 101.326 103.676 97.219 107.071 92.34 Z M 62.617 78.071 L 62.575 78.116 L 62.575 105.589 L 46.891 94.83 L 46.89 94.831 L 46.889 94.831 L 46.606 95.133 L 33.007 85.805 L 32.414 85.805 L 32.971 85.283 L 32.965 85.279 L 21.323 85.279 C 21.407 85.455 21.491 85.63 21.577 85.805 L 12.423 85.805 L 12.423 85.279 L 12.381 85.279 L 12.381 41.251 L 21.882 41.251 C 22.944 39.139 24.138 37.138 25.45 35.252 L 31.842 41.251 L 32.981 41.251 L 46.29 32.122 L 37.732 23.002 C 54.236 11.548 78.317 11.292 96.017 23.754 L 68.489 51.029 C 67.014 50.772 65.507 50.772 64.032 51.029 L 62.617 49.521 L 62.617 21.478 L 62.575 21.507 L 62.575 49.476 L 62.617 49.521 L 62.617 78.071 Z M 46.89 94.83 L 46.891 94.83 L 62.575 78.116 L 62.575 49.476 L 46.607 32.46 L 33.023 41.777 L 32.403 41.777 L 53.493 61.568 C 53.236 63.042 53.236 64.55 53.494 66.024 L 32.971 85.283 L 46.889 94.831 L 46.89 94.83 Z M 25.432 92.357 C 23.974 90.264 22.688 88.073 21.577 85.805 L 32.414 85.805 L 25.432 92.357 Z M 21.622 41.777 L 12.423 41.777 L 12.423 85.279 L 21.323 85.279 C 16.33 74.773 15.089 62.673 18.165 51.087 C 19.041 47.785 20.207 44.679 21.622 41.777 Z M 21.622 41.777 L 32.403 41.777 L 31.842 41.251 L 21.882 41.251 C 21.794 41.426 21.707 41.601 21.622 41.777 Z M 25.45 35.252 L 37.166 22.398 C 37.166 22.398 28.845 30.374 25.45 35.252 Z M 46.607 32.46 L 62.575 21.507 L 62.575 20.952 L 46.29 32.122 L 46.607 32.46 Z" style="stroke: rgb(0, 0, 0); fill: rgba(216, 137, 171, 0.424);"/>
<path d="M 62.153 19.448 L 62.153 108.552 L 32.989 86.966 L 12.41 86.966 L 12.41 40.917 L 33.147 40.917 L 62.153 19.448 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px">
<path d="M 126.467 64.643 C 126.467 88.926 106.782 108.611 82.5 108.611 C 75.312 108.611 68.526 106.886 62.535 103.827 L 62.535 107.62 L 61.931 107.62 L 61.931 108.552 L 32.767 86.966 L 12.188 86.966 L 12.188 40.917 L 32.925 40.917 L 61.931 19.448 L 61.931 20.565 L 62.535 20.565 L 62.535 25.459 C 68.526 22.4 75.312 20.675 82.5 20.675 C 106.782 20.675 126.467 40.36 126.467 64.643 Z M 82.5 105.803 C 105.231 105.803 123.659 87.375 123.659 64.644 C 123.659 41.913 105.231 23.485 82.5 23.485 C 75.256 23.485 68.448 25.357 62.535 28.643 L 62.535 100.645 C 68.448 103.931 75.256 105.803 82.5 105.803 Z M 91.956 31.086 C 105.794 31.086 117.011 46.586 117.011 65.708 C 117.011 84.829 105.794 100.33 91.956 100.33 C 90.811 100.33 89.682 100.223 88.577 100.018 C 100.814 97.736 110.249 83.245 110.249 65.708 C 110.249 48.171 100.815 33.678 88.577 31.399 C 89.682 31.192 90.811 31.086 91.956 31.086 Z M 80.024 34.389 C 92.54 34.389 102.686 48.411 102.686 65.708 C 102.686 83.005 92.54 97.026 80.024 97.026 C 78.986 97.026 77.966 96.93 76.966 96.743 C 88.035 94.68 96.571 81.57 96.571 65.708 C 96.571 49.845 88.036 36.735 76.966 34.674 C 77.965 34.488 78.986 34.389 80.024 34.389 Z M 68.699 36.74 C 80.277 36.74 89.662 49.709 89.662 65.708 C 89.662 81.708 80.277 94.676 68.699 94.676 C 67.74 94.676 66.796 94.588 65.871 94.413 C 76.111 92.506 84.005 80.381 84.005 65.708 C 84.005 51.034 76.111 38.91 65.871 37.002 C 66.796 36.829 67.74 36.74 68.699 36.74 Z" style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

100
build.rs
View File

@ -1,100 +0,0 @@
// embeds assets/Icon.svg into the windows .exe via rsvg-convert, magick, llvm-windres, and rustc-link-arg-bins.
fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" {
return;
}
let svg = "assets/Icon.svg";
let out_dir = std::env::var("OUT_DIR").expect("cargo OUT_DIR");
let tmp = format!("{out_dir}/icon_tmp");
let ico = format!("{out_dir}/icon.ico");
let rc = format!("{out_dir}/icon.rc");
let res = format!("{out_dir}/icon.res");
println!("cargo:rerun-if-changed={svg}");
println!("cargo:rerun-if-changed=build.rs");
if !std::path::Path::new(svg).exists() {
println!("cargo:warning=no {svg} — skipping windows icon embed");
return;
}
let _ = std::fs::create_dir_all(&tmp);
let sizes = [16, 24, 32, 48, 64, 128, 256];
let mut pngs: Vec<String> = Vec::new();
for size in sizes {
let out = format!("{tmp}/icon_{size}.png");
let s = size.to_string();
if !run(&["rsvg-convert", "--width", &s, "--height", &s, svg, "-o", &out]) {
println!("cargo:warning=rsvg-convert failed — windows icon not embedded (brew install librsvg)");
let _ = std::fs::remove_dir_all(&tmp);
return;
}
pngs.push(out);
}
let mut magick_args: Vec<&str> = pngs.iter().map(|s| s.as_str()).collect();
magick_args.push(&ico);
if !run_vec("magick", &magick_args) {
println!("cargo:warning=magick failed — windows icon not embedded (brew install imagemagick)");
let _ = std::fs::remove_dir_all(&tmp);
return;
}
let _ = std::fs::remove_dir_all(&tmp);
if std::fs::write(&rc, "1 ICON \"icon.ico\"\r\n").is_err() {
println!("cargo:warning=could not write {rc} — windows icon not embedded");
return;
}
let Some(windres) = locate_llvm_windres() else {
println!("cargo:warning=llvm-windres not found — windows icon not embedded (brew install llvm)");
return;
};
if !run(&[&windres, "-I", &out_dir, &rc, "-o", &res]) {
println!("cargo:warning=llvm-windres failed — windows icon not embedded");
return;
}
println!("cargo:rustc-link-arg-bins={res}");
}
/// resolves llvm-windres from $LLVM_WINDRES, Homebrew's keg-only llvm prefix on Apple Silicon and Intel, or $PATH.
fn locate_llvm_windres() -> Option<String> {
if let Ok(p) = std::env::var("LLVM_WINDRES") {
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let candidates = [
"/opt/homebrew/opt/llvm/bin/llvm-windres",
"/usr/local/opt/llvm/bin/llvm-windres",
];
for c in candidates {
if std::path::Path::new(c).exists() {
return Some(c.to_string());
}
}
if run(&["sh", "-c", "command -v llvm-windres"]) {
return Some("llvm-windres".to_string());
}
None
}
fn run(args: &[&str]) -> bool {
std::process::Command::new(args[0])
.args(&args[1..])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_vec(cmd: &str, args: &[&str]) -> bool {
std::process::Command::new(cmd)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
}

View File

@ -92,13 +92,6 @@ bool viewport_take_pending_pip_request(struct ViewportHandle *handle);
/// writes the latest left-channel bin dB values into a caller-provided float buffer. returns the number of values actually written.
size_t viewport_get_pip_snapshot(struct ViewportHandle *handle, float *out, size_t max_len);
/// off-screen-renders one PiP frame of the visualizer into a caller-provided BGRA buffer.
void viewport_render_pip_to_bgra(struct ViewportHandle *handle,
uint8_t *dst,
uint32_t dst_stride,
uint32_t width,
uint32_t height);
/// serializes both settings slots as a JSON string. caller frees via viewport_free_string.
char *viewport_get_settings_json(struct ViewportHandle *handle);

View File

@ -32,14 +32,14 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.5</string>
<string>1.0.3</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
<string>iPhoneSimulator</string>
</array>
<key>CFBundleVersion</key>
<string>1.0.5</string>
<string>1.0.3</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@ -58,6 +58,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>picture-in-picture</string>
</array>
<key>UIDeviceFamily</key>
<array>

View File

@ -10,7 +10,7 @@ class CaptureController {
/// true while AVAudioEngine is running through CaptureSession.
var isCapturing: Bool { session.isCapturing }
/// true when the PipController reports an active PiP session.
/// true when the PipController reports an active PiP session. used by CaptureSession to skip teardown on resignActive.
var isPictureInPictureActive: Bool {
if #available(iOS 15.0, *), let p = pipController as? PipController {
return p.isPictureInPictureActive
@ -68,7 +68,7 @@ class CaptureController {
startPipFeedIfPossible()
}
/// lazily creates the iOS-15 PipController and starts its visualizer feed.
/// lazily creates the PipController (iOS 15+) and starts feeding it visualizer snapshots so PiP entry is eligible the moment the user taps the chip.
private func startPipFeedIfPossible() {
guard #available(iOS 15.0, *) else { return }
guard let v = view else { return }

View File

@ -2,16 +2,17 @@ import AVFoundation
import UIKit
/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline.
/// stash-and-restart lifecycle: isCapturing remembers user intent; engine is fully torn down on backgrounding and rebuilt from scratch on every return to active state.
class CaptureSession {
private var engine: AVAudioEngine?
private(set) var isCapturing = false
private var observers: [NSObjectProtocol] = []
/// weak-captured live viewport handle source called once per tap.
/// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
var viewportHandleProvider: (() -> OpaquePointer?)?
/// reports whether Picture-in-Picture is currently active.
/// reports whether Picture-in-Picture is currently active. when true, we skip teardown on resignActive so the engine keeps running in the PiP window.
var pipActiveProvider: (() -> Bool)?
/// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
@ -32,33 +33,17 @@ class CaptureSession {
print("[YrXtals] CaptureSession stopped")
}
/// applies the low-latency mic AVAudioSession configuration with .videoRecording mode.
/// applies the low-latency mic AVAudioSession configuration.
private func configureSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .videoRecording,
options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothA2DP, .allowAirPlay],
mode: .measurement,
options: [.defaultToSpeaker, .mixWithOthers],
)
try session.setPreferredSampleRate(48_000)
try session.setPreferredIOBufferDuration(0.005)
try session.setActive(true, options: [])
pinOutputToSpeakerIfReceiver()
}
/// forces the main speaker only when the current route includes the built-in receiver.
private func pinOutputToSpeakerIfReceiver() {
let session = AVAudioSession.sharedInstance()
let outputs = session.currentRoute.outputs.map { $0.portType }
print("[YrXtals] audio route outputs: \(outputs.map { $0.rawValue })")
if outputs.contains(.builtInReceiver) {
do {
try session.overrideOutputAudioPort(.speaker)
print("[YrXtals] overrode output to .speaker (was on receiver)")
} catch {
print("[YrXtals] override to speaker failed: \(error)")
}
}
}
/// allocates a fresh AVAudioEngine, taps the input bus, and starts the engine.
@ -77,7 +62,6 @@ class CaptureSession {
try engine.start()
self.engine = engine
pinOutputToSpeakerIfReceiver()
}
/// fully tears down anything that might be alive, then runs the configure + tap + start sequence from scratch.
@ -131,13 +115,6 @@ class CaptureSession {
) { [weak self] _ in
self?.handleMediaServicesReset()
})
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: nil,
queue: .main,
) { [weak self] _ in
self?.pinOutputToSpeakerIfReceiver()
})
}
private func unregisterObservers() {
@ -146,7 +123,7 @@ class CaptureSession {
observers.removeAll()
}
/// tears down the engine + session on resignActive, except while a PiP session is active.
/// fully tears down on transition out of active state unless PiP is active, in which case iOS keeps us alive in the floating window and we must keep the engine running.
private func handleResignActive() {
if pipActiveProvider?() == true {
print("[YrXtals] resignActive but PiP active, keeping engine alive")
@ -156,7 +133,7 @@ class CaptureSession {
print("[YrXtals] CaptureSession torn down on resignActive")
}
/// runs the full start sequence on return to active state, only when isCapturing.
/// runs the full start sequence on return to active state, but only when isCapturing was previously set.
private func handleBecomeActive() {
guard isCapturing else { return }
do {
@ -189,7 +166,7 @@ class CaptureSession {
}
}
/// full teardown and full restart triggered by the audio-HAL reset notification.
/// handles iOS resetting the audio HAL: full teardown and full restart when we should be capturing.
private func handleMediaServicesReset() {
teardownEngineAndSession()
guard isCapturing else { return }

View File

@ -163,7 +163,7 @@ final class LibraryController: NSObject,
The File Coordinator might need to Cache. If you can, try files that are not downloaded from iCloud. Even with Keep Downloaded, the Cache of the URLs happens at varied intervals. Close the app, close files, open the app again, try again. It will work if you wait until the URLs have had time to Cache. There is nothing I can do to change this; it's not my bug.
"""
/// claims the security-scoped folder URL and starts the coordinator-with-retry pipeline.
/// claims the security-scoped folder URL and kicks off the coordinator-with-retry pipeline.
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let kind = pendingKind
pendingKind = 0
@ -245,7 +245,7 @@ final class LibraryController: NSObject,
}
}
/// cancels the pending coordinator call and starts the retry countdown after the watchdog elapses.
/// fires when the watchdog elapses without a coordinator success; cancels the in-flight call and starts the retry countdown.
private func handleAttemptTimeout(session: Int) {
guard session == coordSession else { return }
activeCoordinator?.cancel()

View File

@ -4,18 +4,17 @@ import UIKit
import CoreMedia
import CoreVideo
/// drives an AVPictureInPictureController over an AVSampleBufferDisplayLayer fed by the Rust visualizer through an IOSurface-backed CVPixelBuffer.
/// drives an AVPictureInPictureController backed by an AVSampleBufferDisplayLayer that we feed with a simplified bar visualization sampled from the rust analyzer.
@available(iOS 15.0, *)
class PipController: NSObject {
weak var view: IcedViewportView?
private let displayLayer = AVSampleBufferDisplayLayer()
private let containerView = UIView()
private var pipController: AVPictureInPictureController?
private var displayLink: CADisplayLink?
private var pixelBufferPool: CVPixelBufferPool?
private var binsBuffer: [Float] = Array(repeating: 0, count: 64)
private let renderSize = CGSize(width: 640, height: 360)
private let renderSize = CGSize(width: 320, height: 180)
private static let displayTimescale: Int32 = 600
init(view: IcedViewportView) {
@ -23,7 +22,6 @@ class PipController: NSObject {
self.view = view
attachDisplayLayer()
setupPipController()
setupPixelBufferPool()
}
/// returns true when AVPictureInPictureController reports an active PiP session.
@ -31,7 +29,7 @@ class PipController: NSObject {
pipController?.isPictureInPictureActive ?? false
}
/// starts the ~30fps CADisplayLink rendering one PiP frame per tick.
/// begins the CADisplayLink that pulls bin magnitudes and enqueues sample buffers at ~30 fps.
func startFeeding() {
if displayLink != nil { return }
let link = CADisplayLink(target: self, selector: #selector(tick))
@ -47,38 +45,27 @@ class PipController: NSObject {
displayLayer.flushAndRemoveImage()
}
/// toggles PiP, stopping when active and starting when not.
/// requests AVPictureInPictureController to enter PiP. no-op when already active or unsupported.
func enterPip() {
guard let pipController = pipController else {
print("[YrXtals] PiP requested but controller is nil")
return
}
if pipController.isPictureInPictureActive {
pipController.stopPictureInPicture()
return
}
guard let pipController = pipController else { return }
if pipController.isPictureInPictureActive { return }
if !pipController.isPictureInPicturePossible {
print("[YrXtals] PiP rejected: isPictureInPicturePossible=false. supported=\(AVPictureInPictureController.isPictureInPictureSupported()) layerStatus=\(displayLayer.status.rawValue) ready=\(displayLayer.isReadyForMoreMediaData)")
print("[YrXtals] PiP not yet possible (no samples enqueued?)")
return
}
pipController.startPictureInPicture()
}
/// nests the AVSampleBufferDisplayLayer inside a 1pt UIView with a plain CALayer parent.
/// places the AVSampleBufferDisplayLayer in the view hierarchy off-screen so it's a valid PiP source without obscuring the metal layer.
private func attachDisplayLayer() {
containerView.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
containerView.isUserInteractionEnabled = false
containerView.backgroundColor = .clear
view?.addSubview(containerView)
displayLayer.videoGravity = .resizeAspect
displayLayer.frame = containerView.bounds
displayLayer.bounds = containerView.bounds
displayLayer.opacity = 0.01
containerView.layer.addSublayer(displayLayer)
displayLayer.frame = CGRect(origin: .zero, size: renderSize)
displayLayer.bounds = CGRect(origin: .zero, size: renderSize)
displayLayer.opacity = 0
view?.layer.addSublayer(displayLayer)
}
/// configures AVPictureInPictureController against the sample buffer display layer.
/// wires up AVPictureInPictureController with the sample buffer display layer content source.
private func setupPipController() {
guard AVPictureInPictureController.isPictureInPictureSupported() else {
print("[YrXtals] PiP not supported on this device")
@ -90,63 +77,46 @@ class PipController: NSObject {
)
let controller = AVPictureInPictureController(contentSource: source)
controller.delegate = self
if #available(iOS 14.2, *) {
controller.canStartPictureInPictureAutomaticallyFromInline = false
}
pipController = controller
}
/// builds the IOSurface-backed BGRA CVPixelBufferPool sized for the PiP frame.
private func setupPixelBufferPool() {
let attrs: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: Int(kCVPixelFormatType_32BGRA),
kCVPixelBufferWidthKey: Int(renderSize.width),
kCVPixelBufferHeightKey: Int(renderSize.height),
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
kCVPixelBufferMetalCompatibilityKey: true,
kCVPixelBufferCGImageCompatibilityKey: true,
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
]
var pool: CVPixelBufferPool?
let status = CVPixelBufferPoolCreate(
kCFAllocatorDefault,
nil,
attrs as CFDictionary,
&pool,
)
if status == kCVReturnSuccess {
pixelBufferPool = pool
} else {
print("[YrXtals] CVPixelBufferPoolCreate failed: \(status)")
}
}
/// renders one PiP frame from a pooled CVPixelBuffer through the Rust visualizer FFI into the display layer.
/// pulls bin magnitudes from rust, renders a simplified bar visualization into a CMSampleBuffer, enqueues it.
@objc private func tick() {
guard let view = view, let handle = view.viewportHandle else { return }
guard let pool = pixelBufferPool else { return }
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer)
guard status == kCVReturnSuccess, let pb = pixelBuffer else {
let n = binsBuffer.withUnsafeMutableBufferPointer { buf in
viewport_get_pip_snapshot(handle, buf.baseAddress, buf.count)
}
if n == 0 {
// no frames yet, but iOS expects samples enqueue a blank one so PiP eligibility flips on
enqueueRenderedFrame(bins: [])
return
}
CVPixelBufferLockBaseAddress(pb, [])
let base = CVPixelBufferGetBaseAddress(pb)
let stride = UInt32(CVPixelBufferGetBytesPerRow(pb))
let w = UInt32(CVPixelBufferGetWidth(pb))
let h = UInt32(CVPixelBufferGetHeight(pb))
if let dst = base?.assumingMemoryBound(to: UInt8.self) {
viewport_render_pip_to_bgra(handle, dst, stride, w, h)
}
CVPixelBufferUnlockBaseAddress(pb, [])
enqueue(pixelBuffer: pb)
let bins = Array(binsBuffer.prefix(n))
enqueueRenderedFrame(bins: bins)
}
/// wraps the CVPixelBuffer in a CMSampleBuffer with host-clock timing and enqueues onto the display layer.
private func enqueue(pixelBuffer: CVPixelBuffer) {
/// renders bins as vertical bars onto a CVPixelBuffer, wraps in CMSampleBuffer, enqueues to the display layer.
private func enqueueRenderedFrame(bins: [Float]) {
guard let pixelBuffer = makePixelBuffer() else { return }
CVPixelBufferLockBaseAddress(pixelBuffer, [])
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue
| CGImageAlphaInfo.premultipliedFirst.rawValue
guard let ctx = CGContext(
data: base, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: colorSpace, bitmapInfo: bitmapInfo,
) else { return }
drawBars(into: ctx, bins: bins, width: width, height: height)
var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
@ -184,6 +154,46 @@ class PipController: NSObject {
}
displayLayer.enqueue(sb)
}
/// paints the bars onto the CG context. dB values normalized into 0..1 by the standard -60dB..0dB window.
private func drawBars(into ctx: CGContext, bins: [Float], width: Int, height: Int) {
ctx.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
let count = max(bins.count, 1)
let barWidth = CGFloat(width) / CGFloat(count)
let h = CGFloat(height)
for (i, db) in bins.enumerated() {
let normalized = max(0, min(1, (CGFloat(db) + 60) / 60))
let barHeight = normalized * h * 0.9
let x = CGFloat(i) * barWidth
let y = (h - barHeight) / 2
let hue = CGFloat(i) / CGFloat(count)
let color = UIColor(hue: hue, saturation: 0.85, brightness: 1.0, alpha: 1.0).cgColor
ctx.setFillColor(color)
ctx.fill(CGRect(x: x + 1, y: y, width: max(barWidth - 2, 1), height: barHeight))
}
}
/// allocates a BGRA CVPixelBuffer at the render size, IOSurface-backed for AVSampleBufferDisplayLayer compatibility.
private func makePixelBuffer() -> CVPixelBuffer? {
let attrs: [CFString: Any] = [
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
kCVPixelBufferCGImageCompatibilityKey: true,
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
]
var pb: CVPixelBuffer?
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
Int(renderSize.width),
Int(renderSize.height),
kCVPixelFormatType_32BGRA,
attrs as CFDictionary,
&pb,
)
return status == kCVReturnSuccess ? pb : nil
}
}
@available(iOS 15.0, *)
@ -193,6 +203,7 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
_ pictureInPictureController: AVPictureInPictureController,
setPlaying playing: Bool,
) {
// the visualizer "plays" continuously while in PiP; the toggle is ignored.
}
func pictureInPictureControllerTimeRangeForPlayback(
@ -211,6 +222,7 @@ extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
_ pictureInPictureController: AVPictureInPictureController,
didTransitionToRenderSize newRenderSize: CMVideoDimensions,
) {
// ignored; we render at a fixed internal size and let AVKit scale.
}
func pictureInPictureController(
@ -255,12 +267,4 @@ extension PipController: AVPictureInPictureControllerDelegate {
) {
print("[YrXtals] PiP failed to start: \(error)")
}
/// completes AVKit's restore-UI handshake immediately.
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void,
) {
completionHandler(true)
}
}

View File

@ -33,7 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
/// pre-warms the FileProvider XPC chain at launch.
/// nudges the FileProvider XPC chain awake at launch so the first user-triggered picker presentation actually delivers events.
private static func warmDocumentPicker() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
let warm = UIDocumentPickerViewController(forOpeningContentTypes: [.folder, .audio], asCopy: false)

View File

@ -1,299 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-compile + zip distributables from a single macOS host.
#
# Targets: macos-aarch64, macos-x86_64, windows-aarch64, windows-x86_64, linux-aarch64, linux-x86_64.
# Output: dist/YrXtals-<target>.zip per target.
#
# Toolchain prerequisites:
# rustup, zip, codesign
# librsvg, iconutil
# imagemagick, llvm (for the windows .ico embed via build.rs)
# zig, cargo-zigbuild (windows cross-compile)
# docker, cross (linux cross-compile, since cpal pulls alsa-sys)
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
case "$(uname -s)" in
Darwin) ;;
*) echo "package.sh: macOS host only (needs codesign + iconutil for the .app bundle)" >&2; exit 1;;
esac
# mirror .cargo/config.toml's /tmp target dir.
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/yr_crystals-target}"
export CARGO_TARGET_DIR
# raises the per-process file descriptor limit for the linker
ulimit -n 65536 2>/dev/null || ulimit -n 8192 2>/dev/null || true
ALL_TARGETS=(
macos-aarch64
macos-x86_64
windows-aarch64
windows-x86_64
linux-aarch64
linux-x86_64
)
usage() {
cat >&2 <<EOF
usage: cargo xtask package --all
cargo xtask package --target <name> [--target <name> ...]
targets: ${ALL_TARGETS[*]}
EOF
exit 2
}
TARGETS=()
while [ $# -gt 0 ]; do
case "$1" in
--all) TARGETS=("${ALL_TARGETS[@]}"); shift ;;
--target) [ $# -ge 2 ] || usage; TARGETS+=("$2"); shift 2 ;;
-h|--help) usage ;;
*) echo "unknown arg: $1" >&2; usage ;;
esac
done
[ ${#TARGETS[@]} -eq 0 ] && usage
need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 not found. $2" >&2; exit 1; }; }
need rustup "install rustup from https://rustup.rs"
need zip "comes with macOS"
NEEDS_ZIG=0
NEEDS_MAC_ICON=0
NEEDS_WIN_ICON=0
NEEDS_CROSS=0
for t in "${TARGETS[@]}"; do
case "$t" in
windows-*) NEEDS_ZIG=1; NEEDS_WIN_ICON=1 ;;
linux-*) NEEDS_CROSS=1 ;;
macos-*) NEEDS_MAC_ICON=1 ;;
esac
done
if [ $NEEDS_ZIG -eq 1 ]; then
need zig "brew install zig"
need cargo-zigbuild "cargo install cargo-zigbuild"
fi
if [ $NEEDS_MAC_ICON -eq 1 ]; then
need iconutil "comes with Xcode Command Line Tools (xcode-select --install)"
fi
if [ $NEEDS_WIN_ICON -eq 1 ]; then
need rsvg-convert "brew install librsvg"
need magick "brew install imagemagick"
# mirrors build.rs's llvm-windres resolution order.
if ! command -v llvm-windres >/dev/null 2>&1 \
&& [ ! -x /opt/homebrew/opt/llvm/bin/llvm-windres ] \
&& [ ! -x /usr/local/opt/llvm/bin/llvm-windres ] \
&& [ ! -x "${LLVM_WINDRES:-}" ]; then
echo "ERROR: llvm-windres not found. brew install llvm (or set LLVM_WINDRES to its absolute path)" >&2
exit 1
fi
fi
if [ $NEEDS_CROSS -eq 1 ]; then
need cross "cargo install cross"
need docker "brew install --cask docker (or rancher, orbstack, etc) and start the daemon"
fi
PKG="$ROOT/build/package"
DIST="$ROOT/dist"
mkdir -p "$PKG" "$DIST"
# shared 256px PNG for the windows + linux zips.
ICON_PNG="$ROOT/build/icon.png"
if [ ! -f "$ICON_PNG" ] || [ "$ROOT/assets/Icon.svg" -nt "$ICON_PNG" ]; then
if command -v rsvg-convert >/dev/null 2>&1 && [ -f "$ROOT/assets/Icon.svg" ]; then
rsvg-convert --width 256 --height 256 "$ROOT/assets/Icon.svg" -o "$ICON_PNG"
fi
fi
ensure_icns() {
local icns="$ROOT/build/AppIcon.icns"
if [ -f "$icns" ] && [ "$ROOT/assets/Icon.svg" -ot "$icns" ]; then return; fi
[ -f "$ROOT/assets/Icon.svg" ] || return 0
command -v rsvg-convert >/dev/null 2>&1 || return 0
local iconset="$ROOT/build/AppIcon.iconset"
rm -rf "$iconset"
mkdir -p "$iconset"
for size in 16 32 64 128 256 512 1024; do
rsvg-convert --width="$size" --height="$size" \
"$ROOT/assets/Icon.svg" -o "$iconset/icon_${size}.png"
done
cp "$iconset/icon_16.png" "$iconset/icon_16x16.png"
cp "$iconset/icon_32.png" "$iconset/icon_16x16@2x.png"
cp "$iconset/icon_32.png" "$iconset/icon_32x32.png"
cp "$iconset/icon_64.png" "$iconset/icon_32x32@2x.png"
cp "$iconset/icon_128.png" "$iconset/icon_128x128.png"
cp "$iconset/icon_256.png" "$iconset/icon_128x128@2x.png"
cp "$iconset/icon_256.png" "$iconset/icon_256x256.png"
cp "$iconset/icon_512.png" "$iconset/icon_256x256@2x.png"
cp "$iconset/icon_512.png" "$iconset/icon_512x512.png"
cp "$iconset/icon_1024.png" "$iconset/icon_512x512@2x.png"
rm -f "$iconset"/icon_16.png "$iconset"/icon_32.png "$iconset"/icon_64.png \
"$iconset"/icon_128.png "$iconset"/icon_256.png "$iconset"/icon_512.png \
"$iconset"/icon_1024.png
iconutil -c icns "$iconset" -o "$icns"
rm -rf "$iconset"
}
zip_target() {
local target="$1" path="$2"
local out="$DIST/YrXtals-${target}.zip"
rm -f "$out"
(cd "$(dirname "$path")" && zip -r -q "$out" "$(basename "$path")")
echo "$out ($(du -h "$out" | cut -f1))"
}
build_macos() {
local arch="$1" rust_target
case "$arch" in
aarch64) rust_target=aarch64-apple-darwin ;;
x86_64) rust_target=x86_64-apple-darwin ;;
esac
rustup target add "$rust_target" >/dev/null 2>&1 || true
ensure_icns
echo "==> macOS $arch ($rust_target)"
export MACOSX_DEPLOYMENT_TARGET=14.0
cargo build --release --bin yr_crystals --target "$rust_target"
local bin="$CARGO_TARGET_DIR/$rust_target/release/yr_crystals"
[ -f "$bin" ] || { echo "ERROR: yr_crystals missing for $rust_target" >&2; exit 1; }
local stage="$PKG/macos-${arch}"
local app="$stage/Yr Xtals.app"
rm -rf "$stage"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$ROOT/macos/Info.plist" "$app/Contents/Info.plist"
cp "$bin" "$app/Contents/MacOS/yr_crystals"
[ -f "$ROOT/build/AppIcon.icns" ] && cp "$ROOT/build/AppIcon.icns" "$app/Contents/Resources/AppIcon.icns"
codesign --force --sign - "$app"
zip_target "macos-${arch}" "$app"
}
build_windows() {
local arch="$1" rust_target
case "$arch" in
aarch64) rust_target=aarch64-pc-windows-gnullvm ;;
x86_64) rust_target=x86_64-pc-windows-gnu ;;
esac
rustup target add "$rust_target" >/dev/null 2>&1 || true
echo "==> Windows $arch ($rust_target via cargo-zigbuild)"
cargo zigbuild --release --bin yr_crystals --target "$rust_target"
local stage="$PKG/windows-${arch}/YrXtals"
rm -rf "$stage"
mkdir -p "$stage"
cp "$CARGO_TARGET_DIR/$rust_target/release/yr_crystals.exe" "$stage/YrXtals.exe"
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
zip_target "windows-${arch}" "$stage"
}
build_linux() {
local arch="$1" rust_target
case "$arch" in
aarch64) rust_target=aarch64-unknown-linux-gnu ;;
x86_64) rust_target=x86_64-unknown-linux-gnu ;;
esac
# preinstalls the linux-x86_64 toolchain cross mounts into its amd64-only docker image.
rustup toolchain install stable-x86_64-unknown-linux-gnu --profile minimal --force-non-host >/dev/null 2>&1 || true
echo "==> Linux $arch ($rust_target via cross)"
# selects the amd64 variant of cross's image, the only platform manifest cross 0.2.5 publishes on ghcr.io.
# --target-dir target points the build at cross's mounted project volume, overriding the /tmp path from .cargo/config.toml.
(cd "$ROOT" && DOCKER_DEFAULT_PLATFORM=linux/amd64 cross build --release --bin yr_crystals --target "$rust_target" --target-dir target)
local bin="$ROOT/target/$rust_target/release/yr_crystals"
[ -f "$bin" ] || { echo "ERROR: yr_crystals missing for $rust_target" >&2; exit 1; }
local stage="$PKG/linux-${arch}/yrxtals"
rm -rf "$stage"
mkdir -p "$stage"
cp "$bin" "$stage/YrXtals"
chmod +x "$stage/YrXtals"
[ -f "$ICON_PNG" ] && cp "$ICON_PNG" "$stage/icon.png"
[ -f "$ROOT/LICENCE" ] && cp "$ROOT/LICENCE" "$stage/LICENCE"
[ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$stage/README.md"
# self-contained installer bundled into the linux zip.
cat > "$stage/install.sh" <<'INSTALLER_EOF'
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
APP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
mkdir -p "$BIN_DIR" "$APP_DIR" "$ICON_DIR"
pkill -x YrXtals 2>/dev/null || true
sleep 0.2
install -m 755 "$HERE/YrXtals" "$BIN_DIR/YrXtals"
[ -f "$HERE/icon.png" ] && install -m 644 "$HERE/icon.png" "$ICON_DIR/yrxtals.png"
cat > "$APP_DIR/yrxtals.desktop" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Yr Xtals
Comment=Audio visualizer
Exec=$BIN_DIR/YrXtals
Icon=yrxtals
Terminal=false
Categories=AudioVideo;Audio;
DESKTOP
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$APP_DIR" >/dev/null 2>&1 || true
fi
echo "Installed:"
echo " binary → $BIN_DIR/YrXtals"
echo " icon → $ICON_DIR/yrxtals.png"
echo " desktop → $APP_DIR/yrxtals.desktop"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo "note: $BIN_DIR is not on your PATH" >&2 ;;
esac
INSTALLER_EOF
chmod +x "$stage/install.sh"
zip_target "linux-${arch}" "$stage"
}
echo "packaging: ${TARGETS[*]}"
echo
for t in "${TARGETS[@]}"; do
case "$t" in
macos-aarch64) build_macos aarch64 ;;
macos-x86_64) build_macos x86_64 ;;
windows-aarch64) build_windows aarch64 ;;
windows-x86_64) build_windows x86_64 ;;
linux-aarch64) build_linux aarch64 ;;
linux-x86_64) build_linux x86_64 ;;
*) echo "unknown target: $t (valid: ${ALL_TARGETS[*]})" >&2; exit 2 ;;
esac
done
echo
echo "done. dist:"
ls -lh "$DIST"

View File

@ -1,360 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Builds a single self-contained ES module:
# web/dist/yr_crystals_web.js
# WASM is base64-inlined inside the JS. Caller dynamic-imports this URL
# and calls `mount(canvas)` to start the visualizer on any canvas element.
# Also emits dist/index.html for local testing via python3 -m http.server in dist/.
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 not found. $2" >&2; exit 1; }; }
need wasm-pack "cargo install wasm-pack"
need rustup "install rustup from https://rustup.rs"
need python3 "macOS ships it; brew install python3 otherwise"
rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true
DIST="$ROOT/web/dist"
TMP="$ROOT/web/.wasm-pack-tmp"
rm -rf "$DIST" "$TMP"
mkdir -p "$DIST"
echo "==> [1/2] wasm-pack -> $TMP"
(cd "$ROOT/web" && wasm-pack build --release --target web --out-dir "$TMP" --quiet)
echo "==> [2/2] write self-contained yr_crystals_web.js (WASM inlined as base64)"
python3 - <<PY
import base64, pathlib
root = pathlib.Path("$ROOT/web")
tmp = pathlib.Path("$TMP")
dist = pathlib.Path("$DIST")
shim = (tmp / "yr_crystals_web.js").read_text()
wasm_b64 = base64.b64encode((tmp / "yr_crystals_web_bg.wasm").read_bytes()).decode()
wrapper = '''
const __INLINE_WASM_B64 = "''' + wasm_b64 + '''";
let __initPromise = null;
// mounts the wasm visualizer or canvas2D fallback against the canvas.
export async function mount(canvas) {
if (await __hasWebGPU()) {
console.log("[yrxtls] WebGPU available, mounting wasm visualizer");
if (!__initPromise) {
const bin = atob(__INLINE_WASM_B64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
__initPromise = __wbg_init({ module_or_path: bytes });
}
await __initPromise;
start_on_canvas(canvas);
return {
pushBins: (db) => push_bins_db(db),
resize: (w, h) => resize(w, h),
};
}
console.log("[yrxtls] WebGPU unavailable, mounting canvas2D fallback");
return __mountFallback(canvas);
}
function __isMobileOrTablet() {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|webOS/i.test(ua)) return true;
// iPads since iOS 13 report as Macintosh with multi-touch present.
if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true;
return false;
}
async function __hasWebGPU() {
if (__isMobileOrTablet()) return false;
if (typeof navigator === "undefined" || !("gpu" in navigator) || !navigator.gpu) {
return false;
}
try {
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch {
return false;
}
}
function __mountFallback(canvas) {
const ctx = canvas.getContext("2d");
if (!ctx) return { pushBins: () => {} };
const NUM_BINS = 26;
const FADE_BINS = 4;
const HUE_HISTORY_LEN = 40;
const HUE_PARAM = 0.9;
const bins = new Array(NUM_BINS).fill(0);
const brightMod = new Float32Array(NUM_BINS);
const alphaMod = new Float32Array(NUM_BINS);
const hueHistory = [];
let hueSumCos = 0;
let hueSumSin = 0;
let unifiedHue = 0;
let liveDb = null;
const t0 = performance.now() / 1000;
// bins log-spaced from 40Hz to 11kHz projected onto a 20Hz-20kHz log axis, normalized to max 1.0.
const logX = new Float32Array(NUM_BINS);
{
const log40 = Math.log10(40);
const log11k = Math.log10(11000);
const log20 = Math.log10(20);
const log20k = Math.log10(20000);
const range = log20k - log20;
for (let i = 0; i < NUM_BINS; i++) {
const band = i / (NUM_BINS - 1);
const f = Math.pow(10, log40 + band * (log11k - log40));
logX[i] = (Math.log10(f) - log20) / range;
}
const m = logX[NUM_BINS - 1];
for (let i = 0; i < NUM_BINS; i++) logX[i] /= m;
}
// freq_norm and amp_weight constants matching the desktop glass-color formula.
const MID_BAND = Math.floor(NUM_BINS / 2) / (NUM_BINS - 1);
const MID_FREQ = Math.pow(10, Math.log10(40) + MID_BAND * (Math.log10(11000) - Math.log10(40)));
const FREQ_NORM = (Math.log10(MID_FREQ) - Math.log10(20)) / (Math.log10(20000) - Math.log10(20));
const AMP_WEIGHT = Math.max(0.5, Math.min(6, Math.pow(1 / (FREQ_NORM + 1e-4), 5) * 2));
// per-bin hue with the mirror-inverted mapping the desktop ingest applies in default config.
const binHues = new Float32Array(NUM_BINS);
for (let i = 0; i < NUM_BINS; i++) {
binHues[i] = 1 - i / (NUM_BINS - 1);
}
// drives unifiedHue from amp-weighted freq norm through a circular running mean.
function updateUnifiedHue() {
let sum = 0;
for (let i = 0; i < NUM_BINS; i++) sum += bins[i];
const ampNorm = Math.max(0, Math.min(1, sum / NUM_BINS));
let hue = (FREQ_NORM + ampNorm * AMP_WEIGHT * HUE_PARAM) % 1;
if (hue < 0) hue += 1;
hue = 1 - hue;
if (hue < 0) hue += 1;
const angle = hue * Math.PI * 2;
const c = Math.cos(angle);
const s = Math.sin(angle);
hueHistory.push([c, s]);
hueSumCos += c;
hueSumSin += s;
if (hueHistory.length > HUE_HISTORY_LEN) {
const old = hueHistory.shift();
hueSumCos -= old[0];
hueSumSin -= old[1];
}
if (Math.abs(hueSumCos) + Math.abs(hueSumSin) > 0.01) {
const smoothed = Math.atan2(hueSumSin, hueSumCos);
unifiedHue = ((smoothed / (Math.PI * 2)) + 1) % 1;
}
}
// stamps the three-step bright/alpha pattern outward from a peak bin with distance-decayed intensity.
function applyPattern(centre, dist, isBrightSide, direction, peakIntensity, decayBase) {
const target = direction === -1 ? centre - dist : centre + dist - 1;
if (target < 0 || target >= NUM_BINS) return;
const cycle = Math.floor((dist - 1) / 3);
const step = (dist - 1) % 3;
const decay = Math.pow(decayBase, cycle);
const intensity = peakIntensity * decay;
if (intensity < 0.01) return;
const ty = isBrightSide ? (step + 2) % 3 : step;
if (ty === 0) {
brightMod[target] += 0.8 * intensity;
alphaMod[target] -= 0.8 * intensity;
} else if (ty === 1) {
brightMod[target] -= 0.8 * intensity;
alphaMod[target] += 0.2 * intensity;
} else {
brightMod[target] += 0.8 * intensity;
alphaMod[target] += 0.2 * intensity;
}
}
// recomputes per-bin bright/alpha modulations from local-maxima peaks.
function updatePeakMods() {
for (let i = 0; i < NUM_BINS; i++) {
brightMod[i] = 0;
alphaMod[i] = 0;
}
for (let i = 1; i < NUM_BINS - 1; i++) {
const curr = bins[i];
const prev = bins[i - 1];
const next = bins[i + 1];
if (curr > prev && curr > next) {
const leftDominant = prev > next;
const sharpness = Math.min(curr - prev, curr - next);
const peakIntensity = Math.max(0, Math.min(1, Math.pow(Math.max(0, sharpness) * 10, 0.3)));
const decayBase = 0.65 - Math.max(0, Math.min(0.35, sharpness * 3));
for (let d = 1; d <= 12; d++) {
applyPattern(i, d, leftDominant, -1, peakIntensity, decayBase);
applyPattern(i, d, !leftDominant, 1, peakIntensity, decayBase);
}
}
}
}
// quadratic fade ramp over the last FADE_BINS positions.
function segFade(idx, count) {
const fromEnd = count - 1 - idx;
if (fromEnd < FADE_BINS) {
const f = (fromEnd + 1) / (FADE_BINS + 1);
return f * f;
}
return 1;
}
// log-x trapezoid fills under the glass hue with per-bin rainbow spike accents.
function drawSpectrum(baseW, baseH) {
const denom = NUM_BINS - 1;
const fillHue = unifiedHue * 360;
for (let i = 0; i < denom; i++) {
const x1 = logX[i] * baseW;
const x2 = logX[i + 1] * baseW;
const y1 = baseH - bins[i] * baseH;
const y2 = baseH - bins[i + 1] * baseH;
const a = bins[i];
const bm = brightMod[i];
const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2);
const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult));
const lum = brightness * 50;
const am = alphaMod[i];
const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am);
const alphaBase = Math.max(0, Math.min(1, (0.4 + (a - 0.5)) * aMult));
const alpha = alphaBase * segFade(i, denom);
ctx.fillStyle = "hsla(" + fillHue + ", 100%, " + lum + "%, " + alpha + ")";
ctx.beginPath();
ctx.moveTo(x1, baseH);
ctx.lineTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x2, baseH);
ctx.closePath();
ctx.fill();
}
const lineW = Math.max(1, baseW * 0.004);
for (let i = 0; i < NUM_BINS; i++) {
const x = logX[i] * baseW;
const y = baseH - bins[i] * baseH;
const a = bins[i];
const bm = brightMod[i];
const bMult = bm >= 0 ? 1 + bm : 1 / (1 - bm * 2);
const brightness = Math.max(0, Math.min(1, Math.sqrt(a) * bMult));
const lineHue = binHues[i] * 360;
const lum = brightness * 50;
const am = alphaMod[i];
const aMult = Math.max(0.1, am >= 0 ? 1 + am * 0.5 : 1 + am);
const alphaBase = Math.max(0, Math.min(0.9, (0.4 + (a - 0.5)) * aMult));
const alpha = alphaBase * segFade(i, NUM_BINS);
ctx.strokeStyle = "hsla(" + lineHue + ", 90%, " + lum + "%, " + alpha + ")";
ctx.lineWidth = lineW;
ctx.beginPath();
ctx.moveTo(x, baseH);
ctx.lineTo(x, y);
ctx.stroke();
}
}
// reflects the base-rect spectrum into one canvas quadrant.
function drawQuadrant(baseW, baseH, flipX, flipY, w, h) {
ctx.save();
ctx.translate(flipX ? w : 0, flipY ? h : 0);
ctx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
drawSpectrum(baseW, baseH);
ctx.restore();
}
function frame() {
const t = performance.now() / 1000 - t0;
const w = canvas.width;
const h = canvas.height;
if (w === 0 || h === 0) {
requestAnimationFrame(frame);
return;
}
if (liveDb) {
for (let i = 0; i < NUM_BINS; i++) {
const db = i < liveDb.length ? liveDb[i] : -80;
const norm = Math.max(0, Math.min(1, (db + 80) / 80));
bins[i] = bins[i] * 0.55 + norm * 0.45;
}
} else {
// log-frequency sweep with a beat envelope.
for (let i = 0; i < NUM_BINS; i++) {
const band = i / (NUM_BINS - 1);
const sweep = Math.sin(t * 0.4) * 0.5 + 0.5;
const bump = Math.exp(-((band - sweep) ** 2) * 20);
const beat = (Math.sin(t * 2) * 0.5 + 0.5) * 0.4 + 0.6;
const target = bump * beat;
bins[i] = bins[i] * 0.8 + target * 0.2;
}
}
updateUnifiedHue();
updatePeakMods();
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
// (N-1)/(2(N-1)-1) yields a one-bin horizontal mirror overlap for N=26 bins.
const baseW = w * (NUM_BINS - 1) / (2 * (NUM_BINS - 1) - 1);
const baseH = h * 0.5;
drawQuadrant(baseW, baseH, false, false, w, h);
drawQuadrant(baseW, baseH, true, false, w, h);
drawQuadrant(baseW, baseH, false, true, w, h);
drawQuadrant(baseW, baseH, true, true, w, h);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
return {
pushBins: (db) => { liveDb = db; },
resize: () => {},
};
}
'''
(dist / "yr_crystals_web.js").write_text(shim + wrapper)
size = (dist / "yr_crystals_web.js").stat().st_size
print(f" wrote {dist / 'yr_crystals_web.js'} ({size / 1024:.0f} KB)")
# minimal local-test page that imports the same JS by sibling path.
test_html = '''<!DOCTYPE html><html><head><meta charset="utf-8"><title>YrXtls local test</title>
<style>html,body{margin:0;height:100%;background:#111}canvas{display:block;width:100vw;height:100vh}</style>
</head><body><canvas id="stage"></canvas><script type="module">
import { mount } from "./yr_crystals_web.js";
const c = document.getElementById("stage");
const dpr = devicePixelRatio || 1;
const fit = () => {
c.style.width = innerWidth + "px";
c.style.height = innerHeight + "px";
c.width = Math.floor(innerWidth * dpr);
c.height = Math.floor(innerHeight * dpr);
};
fit();
addEventListener("resize", fit);
await mount(c);
</script></body></html>
'''
(dist / "index.html").write_text(test_html)
print(f" wrote {dist / 'index.html'} (local test page)")
PY
cp -R "$ROOT/assets" "$DIST/assets"
echo " copied $ROOT/assets -> $DIST/assets"
cp "$ROOT/web/yr_crystals_embed.js" "$DIST/yr_crystals_embed.js"
echo " copied $ROOT/web/yr_crystals_embed.js -> $DIST/yr_crystals_embed.js"
cp "$ROOT/web/yr_crystals_embed.css" "$DIST/yr_crystals_embed.css"
echo " copied $ROOT/web/yr_crystals_embed.css -> $DIST/yr_crystals_embed.css"
rm -rf "$TMP"
echo
echo "deploy: upload the contents of web/dist/ to your file host (yr_crystals_web.js, yr_crystals_embed.js, yr_crystals_embed.css, assets/)."
echo "test locally: (cd web/dist && python3 -m http.server 8080) then open http://localhost:8080/"

View File

@ -26,12 +26,8 @@ struct Bin {
hue: f32,
sat: f32,
val: f32,
splash: f32, // 0..1 suppressed-energy color splash
};
// 5/12 hue-wheel rotation per full splash, cycling all 12 wheel segments.
const SPLASH_HUE_OFFSET: f32 = 0.4166667;
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var<storage, read> bins: array<Bin>;
@ -88,19 +84,16 @@ fn alpha_for(b: Bin, fade: f32) -> f32 {
fn dyn_rgb(b: Bin) -> vec3<f32> {
let fb = final_brightness(b);
let hue = fract(b.hue + SPLASH_HUE_OFFSET * b.splash);
let s = clamp((b.sat + 0.4 * b.splash) * globals.hue_param, 0.0, 1.0);
let v = clamp((b.val + 0.4 * b.splash) * fb, 0.0, 1.0);
return hsv_to_rgb(hue, s, v);
let s = clamp(b.sat * globals.hue_param, 0.0, 1.0);
let v = clamp(b.val * fb, 0.0, 1.0);
return hsv_to_rgb(b.hue, s, v);
}
fn fill_rgb(b: Bin) -> vec3<f32> {
if (flag(1u)) {
let fb = final_brightness(b);
let hue = fract(globals.unified_hue + SPLASH_HUE_OFFSET * b.splash);
let s = clamp(globals.unified_sat + 0.4 * b.splash, 0.0, 1.0);
let v = clamp((globals.unified_val + 0.4 * b.splash) * fb, 0.0, 1.0);
return hsv_to_rgb(hue, s, v);
let v = clamp(globals.unified_val * fb, 0.0, 1.0);
return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v);
}
return dyn_rgb(b);
}

View File

@ -17,8 +17,6 @@ pub struct FrameData {
pub primary_db: Vec<f32>,
pub cepstrum: Vec<f32>,
pub activity: Vec<f32>,
}
/// stereo three-band processor pool driven by a streaming hilbert source and surfacing one frame per call to step.
@ -48,10 +46,10 @@ pub struct Analyzer {
/// soft cap on the live buffer length.
live_buffer_max: usize,
/// smoothed AGC gain applied to incoming live PCM.
/// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer.
live_gain: f64,
/// dB cutoff for the live-mode noise gate.
/// dB threshold below which incoming live PCM chunks are replaced with silence.
noise_gate_db: f32,
}
@ -138,7 +136,8 @@ impl Analyzer {
self.hilbert_needs_reset = true;
}
/// retunes the three bands' transform lengths and the hilbert hop, force-clearing every per-config buffer.
/// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match.
/// always force-clears processor buffers, smoothing history, hilbert state, and the live buffer so the result of a given (fft, hop) pair doesn't depend on the path taken to get there.
pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) {
let trans_size = (frame_size / 4).max(64);
let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 };
@ -196,42 +195,6 @@ impl Analyzer {
}
}
/// updates the per-bin smoothing frequency-tilt on every processor.
pub fn set_smoothing_tilt(&mut self, tilt: f64) {
for p in self
.main
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.deep.iter_mut())
{
p.set_smoothing_tilt(tilt);
}
}
/// updates the per-bin smoothing strength on every processor.
pub fn set_smoothing_strength(&mut self, strength: f64) {
for p in self
.main
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.deep.iter_mut())
{
p.set_smoothing_strength(strength);
}
}
/// toggles smoothed-cadence splash injection on every processor.
pub fn set_smooth_splashes(&mut self, on: bool) {
for p in self
.main
.iter_mut()
.chain(self.transient.iter_mut())
.chain(self.deep.iter_mut())
{
p.set_smooth_splashes(on);
}
}
/// advances one hop of analytic-signal data at the requested normalised playhead and publishes a stereo frame pair.
pub fn step(&mut self, position: f64) -> Option<&[FrameData]> {
let total = self.total_samples();
@ -288,7 +251,7 @@ impl Analyzer {
}
}
/// appends stereo-converted PCM into the bounded live-mode buffer, tracking the incoming sample rate.
/// appends interleaved PCM into the live-mode buffer with stereo conversion. propagates sample-rate changes and caps the buffer length.
pub fn push_live_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) {
if samples.is_empty() || channels == 0 {
return;
@ -306,6 +269,7 @@ impl Analyzer {
self.hilbert_needs_reset = true;
}
// chunk-RMS AGC: keep mic levels near a fixed target so a quiet phone-mic still drives the visualizer.
const TARGET_RMS: f64 = 0.15;
const MIN_RMS: f64 = 0.001;
const MAX_GAIN: f64 = 50.0;
@ -322,6 +286,7 @@ impl Analyzer {
let ch = channels as usize;
if rms_db < self.noise_gate_db {
// chunk is below the gate — write silence at the same frame count to keep the hop clock alive.
let frames = if ch > 0 { samples.len() / ch } else { 0 };
for _ in 0..frames {
self.live_buffer.push_back(0.0);
@ -371,10 +336,11 @@ impl Analyzer {
self.live_buffer.pop_front();
}
// real-time anchor every PCM push, not only when step_live happens to fire. without this, audio piles up between step_lives whenever the worker is busy and the visualizer slides further behind reality until the buffer hits its 2-second cap.
self.trim_live_buffer();
}
/// drops oldest live-buffer entries down to what the current (fft, hop) needs plus a fixed real-time slack.
/// drops oldest live-buffer entries down to what the current (fft, hop) needs plus a small fixed real-time slack, so the visualizer always works against recent audio.
fn trim_live_buffer(&mut self) {
let hop = self.hilbert_hop_size;
let fft = self.hilbert_fft_size;
@ -387,6 +353,7 @@ impl Analyzer {
} else {
hop * 2
};
// fixed ~30ms slack across all configs — keeps lag bounded regardless of hop size.
let slack_ms = 30usize;
let slack_entries = if self.live_sample_rate == 0 {
hop * 4
@ -411,6 +378,7 @@ impl Analyzer {
return None;
}
// defense-in-depth trim in case PCM stopped arriving briefly (no push_live_pcm calls but old buffered audio still around).
self.trim_live_buffer();
if self.hilbert_needs_reset {
@ -522,7 +490,6 @@ impl Analyzer {
db: spec_main.db,
primary_db,
cepstrum,
activity: std::mem::take(&mut spec_main.activity),
});
}

View File

@ -25,9 +25,6 @@ enum Cmd {
SetNumBins(usize),
SetSmoothing { granularity: i32, detail: i32, strength: f32 },
SetGpuBlend(f32),
SetSmoothingTilt(f32),
SetSmoothingStrength(f32),
SetSmoothSplashes(bool),
SetNoiseGate(f32),
SetMode(AnalyzerMode),
PushLivePcm { samples: Vec<f32>, sample_rate: u32, channels: u32 },
@ -113,21 +110,6 @@ impl AnalyzerWorker {
let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend));
}
/// queues a smoothing-tilt change.
pub fn set_smoothing_tilt(&self, tilt: f32) {
let _ = self.cmd_tx.send(Cmd::SetSmoothingTilt(tilt));
}
/// queues a smoothing-strength change.
pub fn set_smoothing_strength(&self, strength: f32) {
let _ = self.cmd_tx.send(Cmd::SetSmoothingStrength(strength));
}
/// queues a smoothed-splash toggle.
pub fn set_smooth_splashes(&self, on: bool) {
let _ = self.cmd_tx.send(Cmd::SetSmoothSplashes(on));
}
/// queues a noise-gate threshold change in dB.
pub fn set_noise_gate(&self, db: f32) {
let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db));
@ -233,6 +215,7 @@ fn run(
}
}
AnalyzerMode::Live => {
// drains queued hops with a wall-clock budget so heavy fft+small-hop configs don't burn a full tick on a single drain pass and starve the pcm channel.
let drain_deadline = Instant::now() + Duration::from_millis(8);
let mut latest_owned: Option<Vec<FrameData>> = None;
for _ in 0..200 {
@ -271,9 +254,6 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) {
strength,
} => analyzer.set_smoothing_params(granularity, detail, strength),
Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b),
Cmd::SetSmoothingTilt(t) => analyzer.set_smoothing_tilt(t as f64),
Cmd::SetSmoothingStrength(s) => analyzer.set_smoothing_strength(s as f64),
Cmd::SetSmoothSplashes(on) => analyzer.set_smooth_splashes(on),
Cmd::SetNoiseGate(db) => analyzer.set_noise_gate(db),
Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {}
Cmd::Shutdown => {}

View File

@ -537,7 +537,7 @@ fn redirect_stdio_to_logcat() {
.ok();
}
/// opens the AAudio mic input stream and stores the handle on the AndroidViewport, idempotent against an already-running stream.
/// opens the AAudio mic input stream and stores the handle on the AndroidViewport. no-op if a stream is already running.
#[unsafe(no_mangle)]
pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>(
_env: JNIEnv<'a>,

View File

@ -4,7 +4,7 @@ use cpal::{SampleFormat, Stream};
use crate::analyzer_worker::PcmSender;
use crate::devices;
/// owns the cpal input stream feeding the analyzer worker.
/// owns the cpal input stream that pushes captured PCM into the analyzer worker.
pub struct DesktopCapture {
_stream: Stream,
}
@ -15,7 +15,7 @@ impl DesktopCapture {
Self::start_with_device(pusher, None)
}
/// opens an input device by name, falling back to the system default when name is None.
/// opens an input device by name (system default when name is None) and starts the capture stream.
pub fn start_with_device(pusher: PcmSender, name: Option<&str>) -> Result<Self, String> {
let device = devices::input_device(name)
.ok_or_else(|| "no input device available".to_string())?;

View File

@ -66,7 +66,7 @@ impl AudioEngine {
Self::with_output_device(None)
}
/// opens a named output device or the system default, building a paused f32 stream against a started audio thread.
/// opens an output device by name (system default when name is None), builds the f32 stream, and starts the audio thread paused.
#[cfg(not(target_os = "android"))]
pub fn with_output_device(name: Option<&str>) -> Result<Self, String> {
#[cfg(debug_assertions)]

View File

@ -18,10 +18,10 @@ struct Args {
const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64;
/// pipelined slot count sustaining the one-frame lag pattern without scratch-buffer barrier-serialization.
/// number of pipelined slots. 2 is enough for a one-frame lag (one in-flight + one available for new submit) — each slot's scratch buffer is free by the time we cycle back to it.
const NUM_SLOTS: usize = 2;
/// per-slot gpu resources owned exclusively by one pending submission.
/// per-slot gpu resources: each in-flight submission gets exclusive use of its slot, so the next submission can be queued onto a different slot without barriers serializing them on the shared scratch buffer.
struct GpuSlot {
data_buf: wgpu::Buffer,
scratch_buf: wgpu::Buffer,
@ -31,7 +31,7 @@ struct GpuSlot {
staging_mapped: bool,
}
/// fixed-size 1D radix-2 FFT with N pipelined slots, polled by SubmissionIndex.
/// fixed-size 1D radix-2 FFT with N pipelined slots. submit_forward queues work onto the next slot; try_collect_into drains the oldest pending submission specifically by its SubmissionIndex (no device-wide wait).
pub struct GpuFft1D {
device: wgpu::Device,
queue: wgpu::Queue,
@ -50,7 +50,7 @@ pub struct GpuFft1D {
}
impl GpuFft1D {
/// allocates pipelines, per-slot buffers and bind groups for an N-point FFT, and primes the first slot with a zero submission.
/// allocates pipelines, per-slot buffers and bind groups for an N-point FFT (N a power of two), then primes the first slot with a zero submission so the first try_collect_into never returns false.
pub fn new(device: wgpu::Device, queue: wgpu::Queue, n: u32) -> Self {
assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2");
let log2_n = n.trailing_zeros();
@ -197,6 +197,7 @@ impl GpuFft1D {
pending: VecDeque::with_capacity(NUM_SLOTS),
};
// prime: queue one zero submission so the first real call's collect always returns something (the zero result) instead of None. only one prime is needed for the lag pattern; the other slot stays available for the first real submit.
let zero = vec![Complex64::new(0.0, 0.0); n as usize];
this.submit_forward(&zero);
@ -219,13 +220,14 @@ impl GpuFft1D {
self.submit(input, true);
}
/// drains the oldest pending submission into the output slice, returning false only when nothing is pending.
/// drains the oldest pending submission into the output slice. returns false only when nothing is pending (the prime guarantees this never happens in steady state).
pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool {
debug_assert_eq!(out.len(), self.n as usize);
let Some((slot_idx, sub_idx)) = self.pending.pop_front() else {
return false;
};
// wait specifically for OUR submission, not the device's most recent globally. this keeps the wait deterministic when other rayon threads and the iced renderer are also submitting to the same device.
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: Some(sub_idx),
timeout: None,
@ -237,6 +239,7 @@ impl GpuFft1D {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// drive the map callback. submission is already complete, so this returns quickly.
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().expect("map_async result").expect("map ok");
slot.staging_mapped = true;
@ -253,7 +256,7 @@ impl GpuFft1D {
true
}
/// drains every pending submission without reading its staging buffer and re-primes with a single zero submission.
/// drains every pending submission without reading its staging buffer, then re-primes with a single zero submission. used when processor state is cleared so the next get_spectrum sees a fresh pipeline instead of a stale frame from before the reset.
pub fn reset(&mut self) {
while let Some((slot_idx, sub_idx)) = self.pending.pop_front() {
let _ = self.device.poll(wgpu::PollType::Wait {
@ -298,7 +301,7 @@ impl GpuFft1D {
self.cached_inverse.set(Some(inverse));
}
/// dispatches one bit-reversal and log2(N) butterfly transform of the input into the next pipelined slot.
/// uploads input to the next slot, dispatches bit-reversal + log2(N) butterfly passes against that slot's exclusive resources, queues the readback copy, and records the submission index for later collection.
fn submit(&mut self, input: &[Complex64], inverse: bool) {
debug_assert_eq!(input.len(), self.n as usize);
self.ensure_all_args(inverse);

View File

@ -406,19 +406,6 @@ pub extern "C" fn viewport_take_pending_persist_settings(handle: *mut ViewportHa
h.take_pending_persist_settings()
}
/// off-screen-renders one PiP frame of the visualizer into the provided BGRA buffer at the given row stride.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_render_pip_to_bgra(
handle: *mut ViewportHandle,
dst: *mut u8,
dst_stride: u32,
width: u32,
height: u32,
) {
let Some(h) = (unsafe { handle.as_mut() }) else { return };
h.render_pip_to_bgra(dst, dst_stride, width, height);
}
/// releases a CString previously handed to Swift over the FFI boundary.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_free_string(s: *mut c_char) {

View File

@ -1,5 +1,3 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
/// hands off to the winit-driven shell.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
fn main() {

View File

@ -16,11 +16,8 @@ pub struct Spectrum {
pub db: Vec<f32>,
pub cepstrum: Vec<f32>,
pub activity: Vec<f32>,
}
/// single-channel windowed-fft pipeline with optional gpu offload, log-spaced binning, and cepstral envelope blending.
pub struct Processor {
frame_size: usize,
@ -40,19 +37,8 @@ pub struct Processor {
window: Vec<f64>,
buffer: Vec<Complex64>,
sample_freqs: Vec<f64>,
custom_bins: Vec<f64>,
freqs_const: Vec<f64>,
num_bins: usize,
bin_alphas: Vec<f64>,
bin_smoothed: Vec<f64>,
bin_activity: Vec<f64>,
bin_reservoir: Vec<f64>,
bin_prev_delta: Vec<f64>,
bin_initialized: bool,
smoothing_tilt: f64,
smoothing_strength: f64,
smooth_splashes: bool,
history: VecDeque<Vec<f64>>,
smoothing_length: usize,
@ -66,7 +52,6 @@ pub struct Processor {
detail: i32,
cepstral_strength: f32,
#[allow(dead_code)]
weave_caps: Option<weave::Caps>,
}
@ -85,18 +70,8 @@ impl Processor {
gpu_blend: 0.0,
window: Vec::new(),
buffer: Vec::new(),
sample_freqs: Vec::new(),
custom_bins: Vec::new(),
freqs_const: Vec::new(),
bin_alphas: Vec::new(),
bin_smoothed: Vec::new(),
bin_activity: Vec::new(),
bin_reservoir: Vec::new(),
bin_prev_delta: Vec::new(),
bin_initialized: false,
smoothing_tilt: DEFAULT_SMOOTHING_TILT,
smoothing_strength: DEFAULT_SMOOTHING_STRENGTH,
smooth_splashes: true,
num_bins: 26,
history: VecDeque::new(),
smoothing_length: 3,
expand_ratio: 1.0,
@ -117,23 +92,6 @@ impl Processor {
self.sample_rate = rate;
}
/// updates the per-bin smoothing alpha curve's frequency-tilt.
pub fn set_smoothing_tilt(&mut self, tilt: f64) {
self.smoothing_tilt = tilt.clamp(0.0, 1.0);
self.rebuild_smoothing_alphas();
}
/// updates the per-bin smoothing mix between raw FFT and smoothed values.
pub fn set_smoothing_strength(&mut self, strength: f64) {
self.smoothing_strength = strength.clamp(0.0, 1.0);
self.rebuild_smoothing_alphas();
}
/// toggles splash injection between the smoothed-envelope-peak cadence and the raw transient cadence.
pub fn set_smooth_splashes(&mut self, on: bool) {
self.smooth_splashes = on;
}
/// caps the rolling history depth of the per-bin db time-average.
pub fn set_smoothing(&mut self, history_length: usize) {
self.smoothing_length = history_length.max(1);
@ -165,64 +123,27 @@ impl Processor {
self.gpu_blend = blend.clamp(0.0, 1.0);
}
/// rebuilds the 40 hz to 11 khz bin edges and matching center frequencies for the n+1 output columns.
/// rebuilds the geometric 40 hz to 11 khz bin edges and the matching center frequencies for the n+1 output columns.
pub fn set_num_bins(&mut self, n: usize) {
self.num_bins = n.max(1);
self.rebuild_bins();
}
/// rebuilds the log-spaced display bin centers from 40Hz to 11kHz with a 10Hz sentinel prepended.
fn rebuild_bins(&mut self) {
self.sample_freqs.clear();
self.custom_bins.clear();
self.freqs_const.clear();
self.history.clear();
let n = self.num_bins.max(1);
let min_freq = 40.0_f64;
let max_freq = 11_000.0_f64;
let mut edges: Vec<f64> = Vec::with_capacity(n + 1);
for i in 0..=n {
let t = i as f64 / n as f64;
edges.push(min_freq * (max_freq / min_freq).powf(t));
let f = min_freq * (max_freq / min_freq).powf(i as f64 / n as f64);
self.custom_bins.push(f);
}
self.sample_freqs.push(10.0);
self.freqs_const.push(10.0);
for i in 0..n {
let center = (edges[i] + edges[i + 1]) / 2.0;
self.sample_freqs.push(center);
self.freqs_const.push(center);
}
self.rebuild_smoothing_alphas();
let cols = self.freqs_const.len();
self.bin_smoothed = vec![-100.0; cols];
self.bin_activity = vec![0.0; cols];
self.bin_reservoir = vec![0.0; cols];
self.bin_prev_delta = vec![0.0; cols];
self.bin_initialized = false;
}
/// rebuilds per-bin one-pole smoothing alpha values from tilt and strength.
fn rebuild_smoothing_alphas(&mut self) {
let tilt = self.smoothing_tilt.clamp(0.0, 1.0);
let strength = self.smoothing_strength.clamp(0.0, 1.0);
let f_low = 40.0_f64.ln();
let f_high = 11_000.0_f64.ln();
let span = f_high - f_low;
self.bin_alphas.clear();
self.bin_alphas.reserve(self.freqs_const.len());
for &fc in self.freqs_const.iter() {
let f_norm = ((fc.max(1.0).ln() - f_low) / span).clamp(0.0, 1.0);
let curve = 0.5 + 0.45 * tilt * (2.0 * f_norm - 1.0);
let smooth_alpha = curve.clamp(0.05, 0.95);
let effective = 1.0 - strength * (1.0 - smooth_alpha);
self.bin_alphas.push(effective);
for i in 0..self.custom_bins.len() - 1 {
self.freqs_const
.push((self.custom_bins[i] + self.custom_bins[i + 1]) / 2.0);
}
}
/// zeros the analytic-signal buffer, drops the rolling history, and resets the gpu pipeline.
/// zeros the analytic-signal buffer, drops the rolling history, and resets the gpu pipeline. cheap reset that doesn't touch fft plans.
pub fn clear_state(&mut self) {
for c in self.buffer.iter_mut() {
*c = Complex64::new(0.0, 0.0);
@ -233,7 +154,7 @@ impl Processor {
}
}
/// rebuilds fft plans, the analysis window, the working buffer, and the bin layout at the requested transform length.
/// rebuilds fft plans, the blackman-harris window, and the working buffer at the requested transform length.
pub fn set_frame_size(&mut self, size: usize) {
if self.frame_size == size {
return;
@ -244,13 +165,25 @@ impl Processor {
self.cpu_cep_inv = Some(self.cpu_planner.plan_fft_inverse(size));
self.gpu_fft = Some(GpuFft1D::new(self.device.clone(), self.queue.clone(), size as u32));
self.window = build_window(size);
self.window = (0..size)
.map(|i| {
let a0 = 0.35875;
let a1 = 0.48829;
let a2 = 0.14128;
let a3 = 0.01168;
let denom = (size - 1) as f64;
a0 - a1 * (2.0 * PI * i as f64 / denom).cos()
+ a2 * (4.0 * PI * i as f64 / denom).cos()
- a3 * (6.0 * PI * i as f64 / denom).cos()
})
.collect();
self.buffer = vec![Complex64::new(0.0, 0.0); size];
self.history.clear();
self.rebuild_bins();
}
/// fills the analytic-signal buffer with the latest frame_size samples of an incoming chunk, evicting the head.
/// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head.
/// when the chunk is larger than the frame, only the latest frame_size samples are retained.
pub fn push_data(&mut self, data: &[Complex64]) {
let n = self.frame_size;
if n == 0 {
@ -291,6 +224,7 @@ impl Processor {
None
};
// lag-pattern gpu read: collect the previously-queued submission's result, then queue this frame's work. cpu and gpu overlap across the rayon scope so high call rates (small hop) don't serialize on per-call wait.
let gpu_work = if blend > 0.0 {
let gpu = self.gpu_fft.as_mut().expect("gpu fft plan");
let mut prev = vec![Complex64::new(0.0, 0.0); n];
@ -337,8 +271,8 @@ impl Processor {
} else if freq == 0.0 {
mag = 0.0;
}
freqs_full[i] = freq;
mag_full[i] = mag;
freqs_full[i] = freq;
}
let mut cep_buf: Vec<Complex64> = Vec::with_capacity(n);
@ -358,44 +292,23 @@ impl Processor {
.map(|i| (cep_buf[i].re * cep_scale) as f32)
.collect();
let cols = self.freqs_const.len();
if self.bin_smoothed.len() != cols {
self.bin_smoothed = vec![-100.0; cols];
self.bin_activity = vec![0.0; cols];
self.bin_reservoir = vec![0.0; cols];
self.bin_prev_delta = vec![0.0; cols];
self.bin_initialized = false;
if self.cepstral_strength > 0.0 {
let envelope = weave::idealize_curve(
&mag_full,
self.granularity,
self.detail,
self.weave_caps,
);
let s = self.cepstral_strength as f64;
for (m, e) in mag_full.iter_mut().zip(envelope.iter()) {
*m = *m * (1.0 - s) + e * s;
}
let mut current_db = vec![0.0_f64; cols];
for (i, &target) in self.sample_freqs.iter().enumerate() {
}
let mut current_db = vec![0.0_f64; self.freqs_const.len()];
for (i, &target) in self.freqs_const.iter().enumerate() {
let mag = lerp_at(&freqs_full, &mag_full, target);
let raw = 20.0 * mag.max(1e-12).log10();
let smoothed = if !self.bin_initialized {
raw
} else {
let a = self.bin_alphas.get(i).copied().unwrap_or(1.0);
self.bin_smoothed[i] + a * (raw - self.bin_smoothed[i])
};
// suppressed-transient energy as the positive residual between raw and the smoothed display.
let residual = (raw - smoothed).max(0.0);
let norm = (residual / ACTIVITY_REF_DB).clamp(0.0, 1.0);
let delta = smoothed - self.bin_smoothed[i];
if self.smooth_splashes {
self.bin_reservoir[i] += norm;
let peaked = self.bin_prev_delta[i] > 0.0 && delta <= 0.0;
if peaked {
let dump = (self.bin_reservoir[i] * ACTIVITY_RESERVOIR_SCALE).clamp(0.0, 1.0);
self.bin_activity[i] = dump.max(self.bin_activity[i]);
self.bin_reservoir[i] = 0.0;
} else {
self.bin_activity[i] *= ACTIVITY_DECAY;
}
} else {
self.bin_activity[i] = norm.max(self.bin_activity[i] * ACTIVITY_DECAY);
}
self.bin_prev_delta[i] = delta;
self.bin_smoothed[i] = smoothed;
let mut val = smoothed;
let mut val = 20.0 * mag.max(1e-12).log10();
if (self.expand_ratio - 1.0).abs() > f32::EPSILON {
let t = self.expand_threshold as f64;
let r = self.expand_ratio as f64;
@ -406,8 +319,6 @@ impl Processor {
}
current_db[i] = val;
}
self.bin_initialized = true;
let activity_ret: Vec<f32> = self.bin_activity.iter().map(|&a| a as f32).collect();
self.history.push_back(current_db);
while self.history.len() > self.smoothing_length {
@ -433,7 +344,6 @@ impl Processor {
freqs: freqs_ret,
db: averaged,
cepstrum,
activity: activity_ret,
}
}
@ -464,49 +374,6 @@ fn lerp_at(freqs: &[f64], values: &[f64], target: f64) -> f64 {
v0 + t * (v1 - v0)
}
/// Hamming/Blackman-Harris window cutoff in fft samples.
const SMALL_FFT_THRESHOLD: usize = 4096;
/// default per-bin smoothing alpha-curve frequency-tilt.
const DEFAULT_SMOOTHING_TILT: f64 = 0.6;
/// default per-bin smoothing mix between raw FFT and smoothed values.
const DEFAULT_SMOOTHING_STRENGTH: f64 = 0.7;
/// residual dB mapping to full splash activity.
const ACTIVITY_REF_DB: f64 = 12.0;
/// per-frame peak-hold decay for splash activity.
const ACTIVITY_DECAY: f64 = 0.85;
/// reservoir-to-activity scale at a smoothed-envelope peak.
const ACTIVITY_RESERVOIR_SCALE: f64 = 0.25;
/// builds a Hamming or Blackman-Harris analysis window at the requested size.
fn build_window(size: usize) -> Vec<f64> {
if size == 0 {
return Vec::new();
}
let denom = (size - 1).max(1) as f64;
if size <= SMALL_FFT_THRESHOLD {
(0..size)
.map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / denom).cos())
.collect()
} else {
(0..size)
.map(|i| {
let a0 = 0.35875;
let a1 = 0.48829;
let a2 = 0.14128;
let a3 = 0.01168;
a0 - a1 * (2.0 * PI * i as f64 / denom).cos()
+ a2 * (4.0 * PI * i as f64 / denom).cos()
- a3 * (6.0 * PI * i as f64 / denom).cos()
})
.collect()
}
}
/// chooses lighter idealisation caps on mobile targets and unlimited caps on desktop.
#[cfg(any(target_os = "ios", target_os = "android"))]
fn default_caps() -> Option<weave::Caps> {

View File

@ -40,10 +40,10 @@ pub struct CaptureState {
/// foreign-session playback flag pushed by the host MediaController callback.
pub playing: bool,
/// transport-enable latch flipped true the moment any media session reports metadata.
/// flips true once any media session has reported metadata. drives the transport-enable state.
pub has_session: bool,
/// host-pushed flag tracking notification-listener access for now-playing metadata.
/// host-pushed flag: true when the user has granted notification listener access for now-playing info.
pub notification_access: bool,
/// running total of audio frames received over the FFI capture channel since launch.
@ -74,14 +74,11 @@ pub struct App {
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
pub settings: Settings,
/// stash of the other mode's settings, swapped with the active slot on every mode transition.
/// stash of the other mode's settings. swapped with `settings` on every mode transition.
pub settings_inactive: Settings,
pub show_settings: bool,
/// expands the advanced settings group at the bottom of the panel.
pub show_advanced: bool,
/// shell-side picker request flag drained by the iOS host once per tick.
pub pending_pick: u8,
@ -130,16 +127,16 @@ pub struct App {
/// show_settings copy bridging the middle-tap collapse cycle.
pub saved_show_settings: bool,
/// selected output device name on desktop, or None for the system default.
/// selected output device name on desktop (None = system default).
pub output_device: Option<String>,
/// selected input device name on desktop, or None for the system default.
/// selected input device name on desktop (None = system default).
pub input_device: Option<String>,
/// cached list of output device names.
/// cached list of output device names refreshed on demand.
pub output_devices: Vec<String>,
/// cached list of input device names.
/// cached list of input device names refreshed on demand.
pub input_devices: Vec<String>,
/// pending flag drained by the shell to rebuild the capture stream after an input-device change.
@ -168,52 +165,16 @@ pub struct Settings {
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
pub gpu_blend: f32,
/// per-bin smoothing alpha-curve frequency-tilt.
#[serde(default = "default_smoothing_tilt")]
pub smoothing_tilt: f32,
/// per-bin smoothing mix between raw FFT and smoothed values.
#[serde(default = "default_smoothing_strength")]
pub smoothing_strength: f32,
/// fires color splashes at the smoothed-envelope cadence rather than the raw transient cadence.
#[serde(default = "default_smooth_splashes")]
pub smooth_splashes: bool,
/// intensity multiplier on the suppressed-energy color splash.
#[serde(default = "default_splash")]
pub splash: f32,
/// dB cutoff for the live-mode noise gate.
/// dB threshold gating live-mode PCM chunks. chunks below this RMS are replaced with silence.
#[serde(default = "default_noise_gate_db")]
pub noise_gate_db: f32,
}
/// default noise-gate threshold.
/// default noise-gate threshold used when older settings JSON omits the field.
fn default_noise_gate_db() -> f32 {
-60.0
}
/// default per-bin smoothing tilt.
fn default_smoothing_tilt() -> f32 {
0.6
}
/// default per-bin smoothing strength.
fn default_smoothing_strength() -> f32 {
0.7
}
/// default smoothed-splash toggle.
fn default_smooth_splashes() -> bool {
true
}
/// default splash intensity.
fn default_splash() -> f32 {
1.0
}
impl Default for Settings {
fn default() -> Self {
Self {
@ -233,10 +194,6 @@ impl Default for Settings {
detail: 50,
strength: 0.0,
gpu_blend: 0.7,
smoothing_tilt: default_smoothing_tilt(),
smoothing_strength: default_smoothing_strength(),
smooth_splashes: default_smooth_splashes(),
splash: default_splash(),
noise_gate_db: default_noise_gate_db(),
}
}
@ -263,7 +220,6 @@ pub enum Message {
ToggleImmersive,
ToggleChrome,
ToggleSettings,
ToggleAdvanced,
SetPlaybackMode(PlaybackMode),
OpenLocalMode,
OpenCaptureMode,
@ -289,10 +245,6 @@ pub enum Message {
SetDetail(i32),
SetStrength(f32),
SetGpuBlend(f32),
SetSmoothingTilt(f32),
SetSmoothingStrength(f32),
SetSmoothSplashes(bool),
SetSplash(f32),
SetNoiseGate(f32),
SetOutputDevice(Option<String>),
SetInputDevice(Option<String>),
@ -302,7 +254,7 @@ pub enum Message {
PickedFiles(Vec<PathBuf>),
}
/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode.
/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode. android/desktop keep the existing 26-bin default.
#[cfg(target_os = "ios")]
fn capture_dsp_defaults() -> (usize, usize, usize) {
(4096, 1024, 15)
@ -350,9 +302,6 @@ impl App {
worker.set_dsp_params(settings.fft as usize, settings.hop as usize);
worker.set_smoothing(settings.granularity, settings.detail, settings.strength);
worker.set_gpu_blend(settings.gpu_blend);
worker.set_smoothing_tilt(settings.smoothing_tilt);
worker.set_smoothing_strength(settings.smoothing_strength);
worker.set_smooth_splashes(settings.smooth_splashes);
worker.set_noise_gate(settings.noise_gate_db);
let library_worker = LibraryWorker::spawn();
@ -380,7 +329,6 @@ impl App {
settings,
settings_inactive,
show_settings: false,
show_advanced: false,
pending_pick: 0,
pending_capture_action: 0,
pending_pip_request: false,
@ -406,7 +354,7 @@ impl App {
}
}
/// sets the top-level playback mode, swapping the per-mode settings slots and re-applying worker config on an actual change.
/// sets the top-level playback mode. swaps the active/inactive settings slots and re-applies the worker config when the mode actually changes.
pub fn set_playback_mode(&mut self, mode: PlaybackMode) {
let prev = self.playback_mode;
self.playback_mode = mode;
@ -435,13 +383,10 @@ impl App {
self.settings.strength,
);
self.worker.set_gpu_blend(self.settings.gpu_blend);
self.worker.set_smoothing_tilt(self.settings.smoothing_tilt);
self.worker.set_smoothing_strength(self.settings.smoothing_strength);
self.worker.set_smooth_splashes(self.settings.smooth_splashes);
self.worker.set_noise_gate(self.settings.noise_gate_db);
}
/// resets the active settings slot to the current mode's platform defaults and re-applies to the worker.
/// replaces the active settings slot with the platform defaults for the current mode and re-applies them.
pub fn reset_settings_to_defaults(&mut self) {
self.settings = settings_for_mode(self.playback_mode);
self.apply_settings_to_worker();
@ -454,7 +399,7 @@ impl App {
v
}
/// serializes both settings slots as JSON, keyed by mode.
/// serializes both settings slots as JSON, keyed by mode. used by the host shell to write to disk.
pub fn settings_json(&self) -> String {
let (local, capture) = match self.playback_mode {
PlaybackMode::Local => (self.settings, self.settings_inactive),
@ -464,7 +409,7 @@ impl App {
serde_json::to_string(&snapshot).unwrap_or_default()
}
/// applies a host-supplied two-slot settings JSON blob, silently dropping malformed input.
/// applies a settings JSON blob (both mode slots) loaded by the host shell at startup. silent no-op on parse failure.
pub fn apply_settings_json(&mut self, json: &str) {
let Ok(parsed) = serde_json::from_str::<PersistedSettings>(json) else { return };
match self.playback_mode {
@ -480,7 +425,7 @@ impl App {
self.apply_settings_to_worker();
}
/// drains the capture-action flag.
/// returns the capture-action flag and clears the slot in one step.
pub fn take_pending_capture_action(&mut self) -> u8 {
let v = self.pending_capture_action;
self.pending_capture_action = 0;
@ -593,7 +538,7 @@ impl App {
}
}
/// drains the picker flag.
/// returns the picker flag and clears the slot in one step.
pub fn take_pending_pick(&mut self) -> u8 {
let p = self.pending_pick;
self.pending_pick = 0;
@ -620,7 +565,7 @@ impl App {
self.show_settings && x >= viewport_width - player::SETTINGS_W && x <= viewport_width
}
/// replaces the library from a folder scan and begins decoding the leading track.
/// scans a folder, replaces the library, queues art, and starts decoding the first track.
fn apply_picked_folder(&mut self, folder: PathBuf) {
#[cfg(all(target_os = "ios", debug_assertions))]
eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display());
@ -935,10 +880,6 @@ impl App {
| Message::SetDetail(_)
| Message::SetStrength(_)
| Message::SetGpuBlend(_)
| Message::SetSmoothingTilt(_)
| Message::SetSmoothingStrength(_)
| Message::SetSmoothSplashes(_)
| Message::SetSplash(_)
| Message::SetNoiseGate(_)
| Message::ResetSettings,
);
@ -1055,9 +996,6 @@ impl App {
self.restore_settings_scroll = true;
}
}
Message::ToggleAdvanced => {
self.show_advanced = !self.show_advanced;
}
Message::SetPlaybackMode(mode) => self.set_playback_mode(mode),
Message::OpenLocalMode => {
self.set_playback_mode(PlaybackMode::Local);
@ -1128,21 +1066,6 @@ impl App {
self.settings.gpu_blend = v.clamp(0.0, 1.0);
self.worker.set_gpu_blend(self.settings.gpu_blend);
}
Message::SetSmoothingTilt(v) => {
self.settings.smoothing_tilt = v.clamp(0.0, 1.0);
self.worker.set_smoothing_tilt(self.settings.smoothing_tilt);
}
Message::SetSmoothingStrength(v) => {
self.settings.smoothing_strength = v.clamp(0.0, 1.0);
self.worker.set_smoothing_strength(self.settings.smoothing_strength);
}
Message::SetSmoothSplashes(on) => {
self.settings.smooth_splashes = on;
self.worker.set_smooth_splashes(on);
}
Message::SetSplash(v) => {
self.settings.splash = v.clamp(0.0, 8.0);
}
Message::SetNoiseGate(v) => {
self.settings.noise_gate_db = v.clamp(-100.0, 0.0);
self.worker.set_noise_gate(self.settings.noise_gate_db);

View File

@ -108,20 +108,18 @@ fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::R
}
}
/// builds the top-bar row of title, mode-dependent folder/file chips, and settings chip.
fn top_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
/// builds the title row plus folder, file, and settings chip buttons.
fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let title = mouse_area(text("Yr Xtals").size(16).color(palette::text()))
.on_press(Message::ReturnToMainMenu);
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
let bar = match app.playback_mode {
PlaybackMode::Local => {
let folder_btn = chip_button("Folder", Message::OpenFolder);
#[cfg(target_os = "ios")]
let file_btn = chip_button("Library", Message::OpenFile);
#[cfg(not(target_os = "ios"))]
let file_btn = chip_button("File", Message::OpenFile);
row![
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
let bar = row![
title,
Space::new().width(Length::Fixed(20.0)),
folder_btn,
@ -130,20 +128,12 @@ fn top_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
Space::new().width(Length::Fill),
settings_btn,
]
}
PlaybackMode::Capture => row![
title,
Space::new().width(Length::Fill),
settings_btn,
],
};
container(
bar.padding(Padding::from([0, 16]))
.padding(Padding::from([0, 16]))
.spacing(0)
.align_y(iced_wgpu::core::Alignment::Center)
.height(Length::Fill),
)
.height(Length::Fill);
container(bar)
.width(Length::Fill)
.height(Length::Fixed(TOP_BAR_H))
.style(panel_style)
@ -406,7 +396,6 @@ fn params_from(s: &super::app::Settings) -> VizParams {
hue: s.hue,
contrast: s.contrast,
brightness: s.brightness,
splash: s.splash,
}
}
@ -432,13 +421,13 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
let body = column![
Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)),
header,
Space::new().height(Length::Fixed(12.0)),
Space::new().height(Length::Fixed(10.0)),
section_label("style"),
toggle_row("glass", s.glass, Message::SetGlass),
toggle_row("album colors", s.album_colors, Message::SetAlbumColors),
toggle_row("mirrored", s.mirrored, Message::SetMirrored),
toggle_row("inverted", s.inverted, Message::SetInverted),
Space::new().height(Length::Fixed(12.0)),
Space::new().height(Length::Fixed(8.0)),
section_label("color"),
slider_row(
"hue",
@ -464,16 +453,18 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
format!("{:.2}", s.brightness),
Message::SetBrightness,
),
Space::new().height(Length::Fixed(8.0)),
section_label("entropy filter"),
toggle_row("enabled", s.entropy_on, Message::SetEntropy),
slider_row(
"splash",
s.splash,
0.0..=8.0,
0.1,
format!("{:.1}", s.splash),
Message::SetSplash,
"strength",
s.entropy_strength,
-1.5..=1.5,
0.05,
format!("{:+.2}", s.entropy_strength),
Message::SetEntropyStrength,
),
toggle_row("smooth splashes", s.smooth_splashes, Message::SetSmoothSplashes),
Space::new().height(Length::Fixed(12.0)),
Space::new().height(Length::Fixed(8.0)),
section_label("dsp"),
slider_row(
"bins",
@ -483,6 +474,7 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
format!("{}", s.num_bins),
|v| Message::SetNumBins(v as u32),
),
pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft),
pow2_slider_row(
"hop",
@ -491,23 +483,43 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
(s.fft / 2).max(64).trailing_zeros(),
Message::SetHop,
),
Space::new().height(Length::Fixed(8.0)),
section_label("cepstral smoothing"),
slider_row(
"tilt",
s.smoothing_tilt,
0.0..=1.0,
0.01,
format!("{:.2}", s.smoothing_tilt),
Message::SetSmoothingTilt,
"granularity",
s.granularity as f32,
1.0..=100.0,
1.0,
format!("{}", s.granularity),
|v| Message::SetGranularity(v as i32),
),
slider_row(
"detail",
s.detail as f32,
1.0..=100.0,
1.0,
format!("{}", s.detail),
|v| Message::SetDetail(v as i32),
),
slider_row(
"strength",
s.smoothing_strength,
s.strength,
0.0..=1.0,
0.01,
format!("{:.2}", s.smoothing_strength),
Message::SetSmoothingStrength,
format!("{:.2}", s.strength),
Message::SetStrength,
),
Space::new().height(Length::Fixed(12.0)),
Space::new().height(Length::Fixed(8.0)),
section_label("fft engine blend"),
slider_row(
"cpu ↔ gpu",
s.gpu_blend,
0.0..=1.0,
0.01,
format!("{:.2}", s.gpu_blend),
Message::SetGpuBlend,
),
Space::new().height(Length::Fixed(8.0)),
section_label("capture noise gate"),
slider_row(
"threshold",
@ -518,13 +530,13 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
Message::SetNoiseGate,
),
]
.spacing(10)
.spacing(8)
.padding(Padding::from(16))
.width(Length::Fixed(SETTINGS_W));
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
let body = body
.push(Space::new().height(Length::Fixed(12.0)))
.push(Space::new().height(Length::Fixed(8.0)))
.push(section_label("audio devices"))
.push(device_row("output", &app.output_devices, app.output_device.as_deref(), Message::SetOutputDevice))
.push(device_row("input", &app.input_devices, app.input_device.as_deref(), Message::SetInputDevice))
@ -533,59 +545,6 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
chip_button("Refresh devices", Message::RefreshDeviceList),
].padding(Padding::from([4, 0])));
let mut body = body
.push(Space::new().height(Length::Fixed(14.0)))
.push(advanced_header(app.show_advanced));
if app.show_advanced {
body = body
.push(section_label("entropy filter"))
.push(toggle_row("enabled", s.entropy_on, Message::SetEntropy))
.push(slider_row(
"strength",
s.entropy_strength,
-1.5..=1.5,
0.05,
format!("{:+.2}", s.entropy_strength),
Message::SetEntropyStrength,
))
.push(Space::new().height(Length::Fixed(10.0)))
.push(section_label("cepstral smoothing"))
.push(slider_row(
"granularity",
s.granularity as f32,
1.0..=100.0,
1.0,
format!("{}", s.granularity),
|v| Message::SetGranularity(v as i32),
))
.push(slider_row(
"detail",
s.detail as f32,
1.0..=100.0,
1.0,
format!("{}", s.detail),
|v| Message::SetDetail(v as i32),
))
.push(slider_row(
"strength",
s.strength,
0.0..=1.0,
0.01,
format!("{:.2}", s.strength),
Message::SetStrength,
))
.push(Space::new().height(Length::Fixed(10.0)))
.push(section_label("fft engine blend"))
.push(slider_row(
"cpu / gpu",
s.gpu_blend,
0.0..=1.0,
0.01,
format!("{:.2}", s.gpu_blend),
Message::SetGpuBlend,
));
}
let scroll = scrollable(body)
.id(settings_scroll_id())
.on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset()))
@ -625,16 +584,16 @@ fn slider_row<'a, F>(
where
F: 'a + Fn(f32) -> Message,
{
let label_w = 120.0;
let value_w = 64.0;
let label_w = 110.0;
let value_w = 60.0;
container(
row![
container(text(label).size(16).color(palette::text_dim()))
container(text(label).size(13).color(palette::text_dim()))
.width(Length::Fixed(label_w)),
slider(range, value, on_change).step(step).width(Length::Fill).height(38.0),
slider(range, value, on_change).step(step).width(Length::Fill).height(28.0),
container(
text(value_text)
.size(16)
.size(13)
.color(palette::text())
.align_x(iced_wgpu::core::alignment::Horizontal::Right)
)
@ -644,7 +603,7 @@ where
.spacing(12)
.align_y(iced_wgpu::core::Alignment::Center)
)
.height(Length::Fixed(54.0))
.height(Length::Fixed(44.0))
.into()
}
@ -673,7 +632,7 @@ where
)
}
/// device-selection row pairing a label with a pick_list of available device names.
/// label + pick_list pair for selecting a sound device by name, with "System default" as the first option.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
fn device_row<'a, F>(
label: &'a str,
@ -715,7 +674,7 @@ where
.into()
}
/// device pick_list option.
/// device pick_list option: either the system default or a specific named device.
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
#[derive(Clone, PartialEq)]
enum DeviceChoice {
@ -744,55 +703,24 @@ where
{
container(
row![
container(text(label).size(16).color(palette::text_dim()))
.width(Length::Fixed(120.0)),
checkbox(value).on_toggle(on_change).size(32),
container(text(label).size(13).color(palette::text_dim()))
.width(Length::Fixed(110.0)),
checkbox(value).on_toggle(on_change).size(26),
]
.spacing(12)
.align_y(iced_wgpu::core::Alignment::Center)
)
.height(Length::Fixed(50.0))
.height(Length::Fixed(40.0))
.into()
}
/// dim small-caps heading separating groups of settings rows.
fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
container(text(label).size(12).color(palette::text_dim()))
container(text(label).size(10).color(palette::text_dim()))
.padding(Padding::from([0, 0]))
.into()
}
/// full-width toggle button heading the collapsible advanced settings group.
fn advanced_header<'a>(expanded: bool) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let caret = if expanded { "v" } else { ">" };
let inner = row![
text("advanced").size(15).color(palette::text()),
Space::new().width(Length::Fill),
text(caret).size(13).color(palette::text_dim()),
]
.align_y(iced_wgpu::core::Alignment::Center)
.padding(Padding::from([10, 12]));
iced_widget::button(inner)
.padding(0)
.width(Length::Fill)
.on_press(Message::ToggleAdvanced)
.style(|_t: &Theme, status: ButtonStatus| {
let a = if matches!(status, ButtonStatus::Hovered) { 0.10 } else { 0.05 };
button::Style {
background: Some(Background::Color(Color { a, ..palette::text() })),
text_color: palette::text(),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 8.0.into(),
},
..Default::default()
}
})
.into()
}
/// translucent backdrop styling for the settings overlay.
fn settings_panel_style(_theme: &Theme) -> container::Style {
container::Style {
@ -809,7 +737,7 @@ fn settings_panel_style(_theme: &Theme) -> container::Style {
}
}
/// bottom transport bar, branched between the file-mode skip/play/scrub set and the capture-mode metadata strip with PiP toggle.
/// bottom transport bar. file-mode renders the local skip/play/scrub set; capture mode renders the metadata strip with a PiP toggle.
fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode {
PlaybackMode::Local => local_transport_bar(app),

View File

@ -45,13 +45,9 @@ pub struct ViewportHandle {
/// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls.
touch_in_sidebar: bool,
/// classification of an active touch over the settings panel into pending, tap, drag, or scroll.
/// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll.
settings_touch: SettingsTouch,
pub state: App,
/// lazy off-screen visualizer renderer feeding the iOS PiP display layer.
#[cfg(target_os = "ios")]
pip_renderer: Option<crate::visualizer::pip::PipRenderer>,
}
/// classifies an in-flight settings-panel touch once its dominant axis is known.
@ -401,7 +397,7 @@ impl ViewportHandle {
self.state.take_pending_pip_request()
}
/// returns a cloned handle to the most recent analyzer frame pair.
/// returns a cloned handle to the most recent analyzer frame pair for read-only host inspection (PiP mirror, debug).
pub fn frame_data_snapshot(&self) -> std::sync::Arc<Vec<crate::analyzer::FrameData>> {
self.state.frame_data.clone()
}
@ -489,40 +485,6 @@ impl ViewportHandle {
self.state.take_pending_capture_seek()
}
/// off-screen-renders one PiP frame of the visualizer into the caller-provided BGRA buffer.
#[cfg(target_os = "ios")]
pub fn render_pip_to_bgra(
&mut self,
dst: *mut u8,
dst_stride: u32,
width: u32,
height: u32,
) {
if width == 0 || height == 0 || dst.is_null() {
return;
}
let renderer = self.pip_renderer.get_or_insert_with(|| {
crate::visualizer::pip::PipRenderer::new(
self.device.clone(),
self.queue.clone(),
width,
height,
)
});
let frames = self.state.frame_data.clone();
let palette = self.state.current_palette.clone();
let params = pip_viz_params(&self.state.settings);
renderer.render_into_bgra(
&frames,
&params,
palette.as_deref().map(|v| v.as_slice()),
width,
height,
dst,
dst_stride,
);
}
/// drains the pending capture-rebuild flag set after an input-device change.
pub fn take_pending_rebuild_capture(&mut self) -> bool {
self.state.take_pending_rebuild_capture()
@ -639,28 +601,9 @@ fn finalise(
touch_in_sidebar: false,
settings_touch: SettingsTouch::None,
state,
#[cfg(target_os = "ios")]
pip_renderer: None,
})
}
/// projects App.Settings down to the visualizer parameter subset.
#[cfg(target_os = "ios")]
fn pip_viz_params(s: &crate::ui::app::Settings) -> crate::visualizer::VizParams {
crate::visualizer::VizParams {
glass: s.glass,
entropy_on: s.entropy_on,
entropy_strength: s.entropy_strength,
album_colors: s.album_colors,
mirrored: s.mirrored,
inverted: s.inverted,
hue: s.hue,
contrast: s.contrast,
brightness: s.brightness,
splash: s.splash,
}
}
/// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame.
fn render(handle: &mut ViewportHandle) {
handle.state.tick();

View File

@ -2,8 +2,6 @@
pub mod build;
pub mod pipeline;
#[cfg(target_os = "ios")]
pub mod pip;
pub mod primitive;
pub mod state;
@ -27,7 +25,6 @@ pub struct VizParams {
pub hue: f32,
pub contrast: f32,
pub brightness: f32,
pub splash: f32,
}
impl Default for VizParams {
@ -42,7 +39,6 @@ impl Default for VizParams {
hue: 0.9,
contrast: 1.0,
brightness: 1.0,
splash: 1.0,
}
}
}

View File

@ -1,260 +0,0 @@
use std::sync::Arc;
use crate::analyzer::FrameData;
use crate::visualizer::pipeline::{
GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED, FLAG_STEREO,
};
use crate::visualizer::{build, VizParams};
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
const BYTES_PER_PIXEL: u32 = 4;
const COPY_BYTES_PER_ROW_ALIGNMENT: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
/// off-screen BGRA visualizer renderer.
pub struct PipRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: VisPipeline,
texture: wgpu::Texture,
view: wgpu::TextureView,
staging: wgpu::Buffer,
width: u32,
height: u32,
padded_bytes_per_row: u32,
}
impl PipRenderer {
/// allocates the off-screen texture, staging readback buffer, and a fresh BGRA-format visualizer pipeline.
pub fn new(device: wgpu::Device, queue: wgpu::Queue, width: u32, height: u32) -> Self {
let pipeline = VisPipeline::for_format(&device, &queue, FORMAT);
let (texture, view, staging, padded_bytes_per_row) =
allocate_targets(&device, width, height);
Self {
device,
queue,
pipeline,
texture,
view,
staging,
width,
height,
padded_bytes_per_row,
}
}
/// reallocates the texture and staging buffer when the requested size changes.
fn ensure_size(&mut self, width: u32, height: u32) {
if self.width == width && self.height == height {
return;
}
let (texture, view, staging, padded_bytes_per_row) =
allocate_targets(&self.device, width, height);
self.texture = texture;
self.view = view;
self.staging = staging;
self.width = width;
self.height = height;
self.padded_bytes_per_row = padded_bytes_per_row;
}
/// renders one frame of the visualizer at the requested resolution and writes the BGRA pixels into dst, packed at dst_stride bytes per row.
pub fn render_into_bgra(
&mut self,
frames: &Arc<Vec<FrameData>>,
params: &VizParams,
palette: Option<&[[f32; 3]]>,
width: u32,
height: u32,
dst: *mut u8,
dst_stride: u32,
) {
if width == 0 || height == 0 || dst.is_null() {
return;
}
self.ensure_size(width, height);
if !frames.is_empty() {
let frames_id = Arc::as_ptr(frames) as usize;
self.pipeline.state.ingest(frames, frames_id, params, palette);
}
let stereo = self.pipeline.state.channels.len() > 1;
let num_channels = self.pipeline.state.channels.len() as u32;
let num_bins = self
.pipeline
.state
.channels
.first()
.map(|c| c.bins.len() as u32)
.unwrap_or(0);
let w_px = width as f32;
let h_px = height as f32;
let (base_w, base_h, instances) = if params.mirrored {
(w_px * 0.55, h_px * 0.5, 4u32)
} else {
(w_px, h_px, 1u32)
};
let mut flags = 0u32;
if params.glass {
flags |= FLAG_GLASS;
}
if params.mirrored {
flags |= FLAG_MIRRORED;
}
if params.inverted {
flags |= FLAG_INVERTED;
}
if stereo {
flags |= FLAG_STEREO;
}
let uc = self.pipeline.state.unified_color;
let globals = GlobalsGpu {
bounds: [w_px, h_px],
base: [base_w, base_h],
num_bins,
num_channels,
flags,
fade_bins: if params.mirrored { 4 } else { 0 },
hue_param: params.hue,
contrast: params.contrast,
brightness: params.brightness,
_pad0: 0.0,
unified_hue: uc[0],
unified_sat: uc[1],
unified_val: uc[2],
_pad1: 0.0,
};
let mut scratch_bins = std::mem::take(&mut self.pipeline.scratch_bins);
let mut scratch_cep = std::mem::take(&mut self.pipeline.scratch_cep);
self.pipeline.state.pack_bins(frames, stereo, &mut scratch_bins);
scratch_cep.clear();
if params.mirrored {
build::build_cepstrum(&mut scratch_cep, &self.pipeline.state, w_px, h_px);
}
self.pipeline.scratch_bins = scratch_bins;
self.pipeline.scratch_cep = scratch_cep;
self.pipeline
.upload(&self.device, &self.queue, &globals, num_channels, num_bins, instances);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("yr_crystals.pip.encoder"),
});
{
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("yr_crystals.pip.clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
let clip = iced_wgpu::core::Rectangle::<u32> {
x: 0,
y: 0,
width: self.width,
height: self.height,
};
self.pipeline.render_into(&mut encoder, &self.view, &clip);
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &self.staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.padded_bytes_per_row),
rows_per_image: Some(self.height),
},
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
let slice = self.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
if rx.recv().ok().and_then(|r| r.ok()).is_none() {
return;
}
{
let view = slice.get_mapped_range();
let row_bytes = (self.width * BYTES_PER_PIXEL) as usize;
let padded = self.padded_bytes_per_row as usize;
let dst_step = dst_stride as usize;
for y in 0..self.height as usize {
let src_row = &view[y * padded..y * padded + row_bytes];
unsafe {
let dst_row = dst.add(y * dst_step);
std::ptr::copy_nonoverlapping(src_row.as_ptr(), dst_row, row_bytes);
}
}
}
self.staging.unmap();
}
}
/// allocates the BGRA render texture, a matching view, and the row-aligned staging readback buffer.
fn allocate_targets(
device: &wgpu::Device,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView, wgpu::Buffer, u32) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("yr_crystals.pip.texture"),
size: wgpu::Extent3d {
width: width.max(1),
height: height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let unpadded = width * BYTES_PER_PIXEL;
let padded_bytes_per_row =
unpadded.div_ceil(COPY_BYTES_PER_ROW_ALIGNMENT) * COPY_BYTES_PER_ROW_ALIGNMENT;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.pip.staging"),
size: (padded_bytes_per_row * height.max(1)) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
(texture, view, staging, padded_bytes_per_row)
}

View File

@ -17,7 +17,6 @@ pub struct BinGpu {
pub hue: f32,
pub sat: f32,
pub val: f32,
pub splash: f32,
}
/// uniform block holding viewport size, layout counts, render flags, and the unified glass color.
@ -83,14 +82,8 @@ const INITIAL_BINS_CAPACITY: u64 = 256 * 2;
const INITIAL_CEP_CAPACITY: u64 = 1024;
impl Pipeline for VisPipeline {
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
Self::for_format(device, queue, format)
}
}
impl VisPipeline {
/// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group for an arbitrary color format.
pub fn for_format(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
/// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group.
fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("yr_crystals.visualizer.shader"),
source: wgpu::ShaderSource::Wgsl(

View File

@ -10,9 +10,6 @@ use crate::visualizer::VizParams;
const HUE_HISTORY_LEN: usize = 40;
const HISTORY_LEN: usize = 30;
/// splash-to-jump response gain, reaching the full offset on a moderate splash.
const SPLASH_GAIN: f32 = 4.0;
/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history.
#[derive(Debug, Clone, Default)]
pub struct BinState {
@ -22,7 +19,6 @@ pub struct BinState {
pub bright_mod: f32,
pub alpha_mod: f32,
pub cached_color: [f32; 3],
pub splash: f32,
pub history: VecDeque<f32>,
}
@ -112,7 +108,6 @@ impl VisState {
hue: b.cached_color[0],
sat: b.cached_color[1],
val: b.cached_color[2],
splash: b.splash,
});
}
}
@ -314,24 +309,6 @@ fn ingest_channel(
}
}
// spreads each bin's suppressed-energy activity into a 2-bin-wide paint splash via a 0.5/1.0/0.5 kernel.
let mut splash = vec![0.0_f32; n];
if frame.activity.len() == n {
for i in 0..n {
let a = frame.activity[i];
if a <= 0.0 {
continue;
}
splash[i] += a;
if i > 0 {
splash[i - 1] += a * 0.5;
}
if i + 1 < n {
splash[i + 1] += a * 0.5;
}
}
}
let use_palette = params.album_colors
&& palette
.map(|p| !p.is_empty())
@ -362,8 +339,6 @@ fn ingest_channel(
}
b.cached_color = [hue, 1.0, 1.0];
}
// per-bin splash amount, applied as a hue/sat/value shift on the gpu for both fills and lines.
b.splash = (splash[i] * SPLASH_GAIN * params.splash).min(1.0);
}
}

View File

@ -1,53 +0,0 @@
[package]
name = "yr_crystals_web"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "yrxtls-serve"
path = "src/server.rs"
[dependencies]
log = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = "27"
bytemuck = { version = "1", features = ["derive"] }
num-complex = "0.4"
rustfft = "6"
raw-window-handle = "0.6"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
console_error_panic_hook = "0.1"
console_log = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
version = "0.3"
features = [
"Document",
"Element",
"HtmlCanvasElement",
"Navigator",
"Window",
"Performance",
"console",
"AudioContext",
"AudioContextOptions",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioDestinationNode",
"AudioNode",
"AnalyserNode",
"MediaElementAudioSourceNode",
"HtmlAudioElement",
"GpuCanvasContext",
]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tiny_http = "0.12"
env_logger = "0.11"

View File

@ -1,17 +0,0 @@
// drops empty dist/ placeholders, keeping the server bin standalone-compilable.
// real bytes populated by the scripts/web/build.sh wasm-pack stage.
fn main() {
let dist = std::path::Path::new("dist");
let _ = std::fs::create_dir_all(dist);
let path = dist.join("index.html");
if !path.exists() {
let _ = std::fs::write(&path, b"");
println!(
"cargo:warning=missing {}, wrote empty placeholder. run scripts/web/build.sh to produce the real bundle.",
path.display()
);
}
println!("cargo:rerun-if-changed={}", path.display());
}

View File

@ -1,132 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>YrXtls</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: #eee;
}
html { height: 100%; }
body { height: 100%; }
#stage {
display: block;
position: fixed;
top: 0;
left: 0;
}
#status {
position: fixed;
top: 12px;
left: 12px;
right: 12px;
font-size: 12px;
white-space: pre-wrap;
line-height: 1.5;
opacity: 0.85;
pointer-events: none;
}
#status.ok { color: #6f6; }
#status.warn { color: #fc6; }
#status.err { color: #f66; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<pre id="status">booting...</pre>
<script type="module">
// these two strings get filled in by scripts/web/build.sh; the unbuilt template ships them empty so editors don't choke on multi-MB lines.
const __JS_B64 = "__JS_BASE64__";
const __WASM_B64 = "__WASM_BASE64__";
const status = document.getElementById("status");
const log = (msg, cls = "") => {
status.textContent += "\n" + msg;
if (cls) status.className = cls;
console.log("[yrxtls]", msg);
};
function sizeCanvas() {
const canvas = document.getElementById("stage");
const dpr = window.devicePixelRatio || 1;
const w = window.innerWidth;
const h = window.innerHeight;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
return [canvas.width, canvas.height];
}
function b64ToBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
async function main() {
try {
status.textContent = "booting...";
log(`location: ${location.href}`);
log(`secure context: ${window.isSecureContext}`);
log(`navigator.gpu: ${typeof navigator.gpu}`);
if (!("gpu" in navigator) || !navigator.gpu) {
log("navigator.gpu missing -> browser has no WebGPU.", "err");
return;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
log("requestAdapter() returned null -> WebGPU rejected.", "err");
return;
}
log(`adapter: ${adapter.info?.vendor ?? "?"} / ${adapter.info?.architecture ?? "?"}`, "ok");
const [w, h] = sizeCanvas();
log(`canvas: ${w}x${h}`);
if (!__JS_B64 || !__WASM_B64) {
log("inlined wasm/js bundles are empty -> this is the unbuilt template, run scripts/web/build.sh.", "err");
return;
}
log("decoding inlined bundles...");
const jsBlob = new Blob([b64ToBytes(__JS_B64)], { type: "text/javascript" });
const jsUrl = URL.createObjectURL(jsBlob);
const mod = await import(jsUrl);
URL.revokeObjectURL(jsUrl);
await mod.default({ module_or_path: b64ToBytes(__WASM_B64) });
log("wasm loaded, starting...", "ok");
mod.start("#stage");
const propagateResize = () => {
const [w, h] = sizeCanvas();
mod.resize(w, h);
};
// ResizeObserver fires immediately for current size and on every layout change.
// critical for iframe embeds where the iframe element grows from its 300x150 default to its real size AFTER the inner script first runs.
const ro = new ResizeObserver(propagateResize);
ro.observe(document.documentElement);
window.addEventListener("resize", propagateResize);
log("running.", "ok");
} catch (e) {
log("FATAL: " + (e?.stack ?? e), "err");
}
}
main();
</script>
</body>
</html>

View File

@ -1,252 +0,0 @@
// synthesizes triangle and line vertices per-bin from a storage buffer, with mirrors as an instance axis.
struct Globals {
bounds: vec2<f32>, // full canvas in pixels
base: vec2<f32>, // building rect in pixels, 0.55w by 0.5h when mirrored
num_bins: u32,
num_channels: u32,
flags: u32, // 1=glass, 2=mirrored, 4=inverted, 8=stereo
fade_bins: u32, // count of tail bins fading toward zero alpha when mirrored
hue_param: f32,
contrast: f32,
brightness: f32,
_pad0: f32,
unified_hue: f32,
unified_sat: f32,
unified_val: f32,
_pad1: f32,
};
struct Bin {
log_x: f32, // 0..1 along the log-frequency axis
visual_norm: f32, // smoothed dB on a 0..1 scale
primary_norm: f32, // primary dB on a 0..1 scale
bright_mod: f32,
alpha_mod: f32,
hue: f32,
sat: f32,
val: f32,
};
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var<storage, read> bins: array<Bin>;
struct VertexOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
};
fn flag(bit: u32) -> bool {
return (globals.flags & bit) != 0u;
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
let hh = fract(fract(h) + 1.0) * 6.0;
let i = floor(hh);
let f = hh - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
let ii = i32(i) % 6;
if (ii == 0) { return vec3<f32>(v, t, p); }
if (ii == 1) { return vec3<f32>(q, v, p); }
if (ii == 2) { return vec3<f32>(p, v, t); }
if (ii == 3) { return vec3<f32>(p, q, v); }
if (ii == 4) { return vec3<f32>(t, p, v); }
return vec3<f32>(v, p, q);
}
fn final_brightness(b: Bin) -> f32 {
let base_b = sqrt(b.primary_norm);
let bm = b.bright_mod;
var b_mult: f32;
if (bm >= 0.0) {
b_mult = 1.0 + bm;
} else {
b_mult = 1.0 / (1.0 - bm * 2.0);
}
return clamp(base_b * b_mult * globals.brightness, 0.0, 1.0);
}
fn alpha_for(b: Bin, fade: f32) -> f32 {
let am = b.alpha_mod;
var a_mult: f32;
if (am >= 0.0) {
a_mult = 1.0 + am * 0.5;
} else {
a_mult = 1.0 + am;
}
a_mult = max(a_mult, 0.1);
var a = 0.4 + (b.primary_norm - 0.5) * globals.contrast;
a = clamp(a * a_mult, 0.0, 1.0);
return a * fade;
}
fn dyn_rgb(b: Bin) -> vec3<f32> {
let fb = final_brightness(b);
let s = clamp(b.sat * globals.hue_param, 0.0, 1.0);
let v = clamp(b.val * fb, 0.0, 1.0);
return hsv_to_rgb(b.hue, s, v);
}
fn fill_rgb(b: Bin) -> vec3<f32> {
if (flag(1u)) {
let fb = final_brightness(b);
let v = clamp(globals.unified_val * fb, 0.0, 1.0);
return hsv_to_rgb(globals.unified_hue, globals.unified_sat, v);
}
return dyn_rgb(b);
}
fn channel_offset(rgb: vec3<f32>, ch: u32) -> vec3<f32> {
if (ch == 1u && flag(8u)) {
let off = 40.0 / 255.0;
return vec3<f32>(
max(rgb.x - off, 0.0),
max(rgb.y - off, 0.0),
min(rgb.z + off, 1.0),
);
}
return rgb;
}
fn fade_factor(seg: u32) -> f32 {
if (!flag(2u)) { return 1.0; }
let from_end = i32(globals.num_bins) - 2 - i32(seg);
if (from_end < i32(globals.fade_bins)) {
var f = f32(from_end + 1) / f32(globals.fade_bins + 1u);
f = f * f;
return max(f, 0.0);
}
return 1.0;
}
fn pixel_to_clip(p: vec2<f32>) -> vec4<f32> {
let nx = (p.x / max(globals.bounds.x, 1.0)) * 2.0 - 1.0;
let ny = 1.0 - (p.y / max(globals.bounds.y, 1.0)) * 2.0;
return vec4<f32>(nx, ny, 0.0, 1.0);
}
fn mirror_xform(iid: u32, p: vec2<f32>) -> vec2<f32> {
var sx: f32 = 1.0;
var sy: f32 = 1.0;
var tx: f32 = 0.0;
var ty: f32 = 0.0;
if (iid == 1u) {
sx = -1.0; tx = globals.bounds.x;
} else if (iid == 2u) {
sy = -1.0; ty = globals.bounds.y;
} else if (iid == 3u) {
sx = -1.0; sy = -1.0;
tx = globals.bounds.x; ty = globals.bounds.y;
}
return vec2<f32>(p.x * sx + tx, p.y * sy + ty);
}
@vertex
fn vs_fill(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut {
let nb = globals.num_bins;
let segs = max(nb, 1u) - 1u;
let per_ch = segs * 6u;
let ch = vid / per_ch;
let in_ch = vid % per_ch;
let seg = in_ch / 6u;
let corner = in_ch % 6u;
var i = seg;
var j = seg + 1u;
if (flag(4u)) {
i = nb - 1u - seg;
j = nb - 2u - seg;
}
let base = ch * nb;
let bi = bins[base + i];
let bj = bins[base + j];
let w = globals.base.x;
let h = globals.base.y;
let x1 = bi.log_x * w;
let x2 = bj.log_x * w;
let y1 = h - bi.visual_norm * h;
let y2 = h - bj.visual_norm * h;
let anchor_y = h;
var p: vec2<f32>;
switch corner {
case 0u: { p = vec2<f32>(x1, anchor_y); }
case 1u: { p = vec2<f32>(x1, y1); }
case 2u: { p = vec2<f32>(x2, y2); }
case 3u: { p = vec2<f32>(x1, anchor_y); }
case 4u: { p = vec2<f32>(x2, y2); }
default: { p = vec2<f32>(x2, anchor_y); }
}
let pw = mirror_xform(iid, p);
var rgb = fill_rgb(bi);
rgb = channel_offset(rgb, ch);
let a = alpha_for(bi, fade_factor(seg));
var out: VertexOut;
out.clip_position = pixel_to_clip(pw);
out.color = vec4<f32>(rgb, a);
return out;
}
@vertex
fn vs_line(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> VertexOut {
let nb = globals.num_bins;
let per_ch = nb * 2u;
let ch = vid / per_ch;
let in_ch = vid % per_ch;
let seg = in_ch / 2u;
let endpoint = in_ch % 2u;
var i = seg;
if (flag(4u)) {
i = nb - 1u - seg;
}
let bi = bins[ch * nb + i];
let w = globals.base.x;
let h = globals.base.y;
let x = bi.log_x * w;
let y_top = h - bi.visual_norm * h;
let anchor_y = h;
var p: vec2<f32>;
if (endpoint == 0u) {
p = vec2<f32>(x, anchor_y);
} else {
p = vec2<f32>(x, y_top);
}
let pw = mirror_xform(iid, p);
var rgb = dyn_rgb(bi);
rgb = channel_offset(rgb, ch);
var a = alpha_for(bi, fade_factor(seg));
a = min(a, 0.9);
var out: VertexOut;
out.clip_position = pixel_to_clip(pw);
out.color = vec4<f32>(rgb, a);
return out;
}
// cepstrum line strip in pixel space.
struct CepIn {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_cep(in: CepIn) -> VertexOut {
var out: VertexOut;
out.clip_position = pixel_to_clip(in.position);
out.color = in.color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
return in.color;
}

View File

@ -1,372 +0,0 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlCanvasElement;
mod palette;
mod visualizer;
use visualizer::pipeline::{
BinGpu, ClipRect, GlobalsGpu, VisPipeline, FLAG_GLASS, FLAG_INVERTED, FLAG_MIRRORED,
FLAG_STEREO,
};
use visualizer::{build as cep_build, FrameData, VizParams};
thread_local! {
/// shared handle to the live App so wasm-bindgen entry points can reach it after start() returns.
static APP_HANDLE: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
/// live per-bin dB values from JS audio analysis.
static LIVE_BINS_DB: RefCell<Option<Vec<f32>>> = const { RefCell::new(None) };
}
/// per-canvas wgpu state plus the visualizer pipeline and the smoothing state it ingests into.
struct App {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: VisPipeline,
params: VizParams,
frames: Vec<FrameData>,
palette: Option<Arc<Vec<[f32; 3]>>>,
start_time: f64,
}
impl App {
/// reconfigures the wgpu surface against new dimensions in physical pixels.
fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 {
return;
}
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
}
/// renders one frame of the visualizer at the surface's current size.
fn render(&mut self) {
let now = web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now() / 1000.0)
.unwrap_or(0.0);
let t = (now - self.start_time) as f32;
synth_frames(&mut self.frames, t);
let Ok(frame) = self.surface.get_current_texture() else {
return;
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let palette = self.palette.as_deref().map(|v| v.as_slice());
let frames_arc: Arc<Vec<FrameData>> = Arc::new(self.frames.clone());
let frames_id = Arc::as_ptr(&frames_arc) as usize;
self.pipeline
.state
.ingest(&self.frames, frames_id, &self.params, palette);
let w_px = self.config.width as f32;
let h_px = self.config.height as f32;
let stereo = self.pipeline.state.channels.len() > 1;
let num_channels = self.pipeline.state.channels.len() as u32;
let num_bins = self
.pipeline
.state
.channels
.first()
.map(|c| c.bins.len() as u32)
.unwrap_or(0);
let (base_w, base_h, instances) = if self.params.mirrored {
// (N-1)/(2(N-1)-1) yields a one-bin horizontal mirror overlap for N=26 bins.
let frac = 25.0_f32 / 49.0_f32;
(w_px * frac, h_px * 0.5, 4u32)
} else {
(w_px, h_px, 1u32)
};
let mut flags = 0u32;
if self.params.glass {
flags |= FLAG_GLASS;
}
if self.params.mirrored {
flags |= FLAG_MIRRORED;
}
if self.params.inverted {
flags |= FLAG_INVERTED;
}
if stereo {
flags |= FLAG_STEREO;
}
let uc = self.pipeline.state.unified_color;
let globals = GlobalsGpu {
bounds: [w_px, h_px],
base: [base_w, base_h],
num_bins,
num_channels,
flags,
fade_bins: if self.params.mirrored { 4 } else { 0 },
hue_param: self.params.hue,
contrast: self.params.contrast,
brightness: self.params.brightness,
_pad0: 0.0,
unified_hue: uc[0],
unified_sat: uc[1],
unified_val: uc[2],
_pad1: 0.0,
};
let mut scratch_bins: Vec<BinGpu> = std::mem::take(&mut self.pipeline.scratch_bins);
let mut scratch_cep = std::mem::take(&mut self.pipeline.scratch_cep);
self.pipeline
.state
.pack_bins(&self.frames, stereo, &mut scratch_bins);
scratch_cep.clear();
if self.params.mirrored {
cep_build::build_cepstrum(&mut scratch_cep, &self.pipeline.state, w_px, h_px);
}
self.pipeline.scratch_bins = scratch_bins;
self.pipeline.scratch_cep = scratch_cep;
self.pipeline
.upload(&self.device, &self.queue, &globals, num_channels, num_bins, instances);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("yr_crystals_web.encoder"),
});
{
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("yr_crystals_web.clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
let clip = ClipRect {
x: 0,
y: 0,
width: self.config.width,
height: self.config.height,
};
self.pipeline.render_into(&mut encoder, &view, &clip);
self.queue.submit(std::iter::once(encoder.finish()));
frame.present();
}
}
/// fills the per-channel spectrum frames from live audio bins, falling back to a synthetic log-sweep.
fn synth_frames(frames: &mut Vec<FrameData>, t: f32) {
const NUM_BINS: usize = 26;
if frames.len() != 2 {
frames.clear();
frames.push(FrameData::default());
frames.push(FrameData::default());
}
let log_min = 40.0_f32.log10();
let log_max = 11_000.0_f32.log10();
let live = LIVE_BINS_DB.with(|c| c.borrow().clone());
for (channel_idx, frame) in frames.iter_mut().enumerate() {
frame.freqs.clear();
frame.db.clear();
frame.primary_db.clear();
frame.cepstrum.clear();
for i in 0..NUM_BINS {
let band = i as f32 / (NUM_BINS as f32 - 1.0);
let freq = 10f32.powf(log_min + (log_max - log_min) * band);
let db = match &live {
Some(bins) if i < bins.len() => bins[i],
_ => {
let sweep = ((t * 0.4 + channel_idx as f32 * 0.2) * std::f32::consts::TAU).sin() * 0.5 + 0.5;
let bump = (-((band - sweep).powi(2)) * 20.0).exp();
let beat = ((t * 2.0).sin() * 0.5 + 0.5) * 0.4 + 0.6;
-80.0 + 70.0 * bump * beat
}
};
frame.freqs.push(freq);
frame.db.push(db);
frame.primary_db.push(db);
}
for i in 0..NUM_BINS / 2 {
frame.cepstrum.push(0.1 * ((t * 4.0 + i as f32 * 0.5).sin()));
}
}
}
/// stores a slice of per-bin dB values as the live frame source.
#[wasm_bindgen]
pub fn push_bins_db(bins_db: &[f32]) {
LIVE_BINS_DB.with(|c| {
let mut slot = c.borrow_mut();
match slot.as_mut() {
Some(v) => {
v.clear();
v.extend_from_slice(bins_db);
}
None => {
*slot = Some(bins_db.to_vec());
}
}
});
}
/// attaches the visualizer to a canvas resolved by the given CSS selector, starting the rAF loop.
#[wasm_bindgen]
pub fn start(canvas_selector: &str) -> Result<(), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("no document"))?;
let canvas: HtmlCanvasElement = document
.query_selector(canvas_selector)?
.ok_or_else(|| JsValue::from_str("canvas selector matched nothing"))?
.dyn_into::<HtmlCanvasElement>()?;
start_on_canvas(canvas)
}
/// attaches the visualizer to the given canvas element directly, starting the rAF loop.
#[wasm_bindgen]
pub fn start_on_canvas(canvas: HtmlCanvasElement) -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let _ = console_log::init_with_level(log::Level::Info);
let width = canvas.width().max(1);
let height = canvas.height().max(1);
let start_time = web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now() / 1000.0)
.unwrap_or(0.0);
spawn_local(async move {
match build_app(canvas, width, height, start_time).await {
Ok(app) => run_loop(app),
Err(e) => log::error!("renderer init failed: {e:?}"),
}
});
Ok(())
}
/// builds the App against the canvas, including the wgpu instance, adapter, surface configuration, and visualizer pipeline.
async fn build_app(
canvas: HtmlCanvasElement,
width: u32,
height: u32,
start_time: f64,
) -> Result<App, JsValue> {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::BROWSER_WEBGPU,
..Default::default()
});
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
.map_err(|e| JsValue::from_str(&format!("create_surface: {e}")))?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.map_err(|e| JsValue::from_str(&format!("request_adapter: {e}")))?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default())
.await
.map_err(|e| JsValue::from_str(&format!("request_device: {e}")))?;
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(|f| !f.is_srgb())
.unwrap_or_else(|| caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width,
height,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let pipeline = VisPipeline::for_format(&device, &queue, format);
Ok(App {
surface,
device,
queue,
config,
pipeline,
params: VizParams::default(),
frames: Vec::new(),
palette: None,
start_time,
})
}
/// rAF-driven render loop publishing the App into APP_HANDLE for the resize export.
fn run_loop(app: App) {
let app = Rc::new(RefCell::new(app));
APP_HANDLE.with(|cell| {
*cell.borrow_mut() = Some(app.clone());
});
let cb: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
let cb_clone = cb.clone();
let app_clone = app.clone();
*cb.borrow_mut() = Some(Closure::wrap(Box::new(move || {
app_clone.borrow_mut().render();
request_frame(cb_clone.borrow().as_ref().unwrap());
}) as Box<dyn FnMut()>));
request_frame(cb.borrow().as_ref().unwrap());
std::mem::forget(cb);
let _ = app;
}
/// reconfigures the wgpu surface against new physical-pixel dimensions reported from JS on window resize.
#[wasm_bindgen]
pub fn resize(width: u32, height: u32) {
APP_HANDLE.with(|cell| {
if let Some(app) = cell.borrow().as_ref() {
app.borrow_mut().resize(width, height);
}
});
}
/// schedules the next rAF tick.
fn request_frame(cb: &Closure<dyn FnMut()>) {
if let Some(window) = web_sys::window() {
let _ = window.request_animation_frame(cb.as_ref().unchecked_ref());
}
}

View File

@ -1,18 +0,0 @@
/// converts an sRGB triple in 0..=1 to hue/saturation/value, all in 0..=1.
pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let d = max - min;
let s = if max <= 0.0 { 0.0 } else { d / max };
let h = if d <= 1e-6 {
0.0
} else if (max - r).abs() < f32::EPSILON {
((g - b) / d).rem_euclid(6.0) / 6.0
} else if (max - g).abs() < f32::EPSILON {
((b - r) / d + 2.0) / 6.0
} else {
((r - g) / d + 4.0) / 6.0
};
((h + 1.0).rem_euclid(1.0), s, v)
}

View File

@ -1,121 +0,0 @@
//! standalone http server serving the embedded WASM visualizer bundle.
#[cfg(target_arch = "wasm32")]
fn main() {}
#[cfg(not(target_arch = "wasm32"))]
use std::env;
#[cfg(not(target_arch = "wasm32"))]
use std::process::ExitCode;
#[cfg(not(target_arch = "wasm32"))]
use tiny_http::{Header, Method, Response, Server, StatusCode};
#[cfg(not(target_arch = "wasm32"))]
const INDEX_HTML: &[u8] = include_bytes!("../dist/index.html");
#[cfg(not(target_arch = "wasm32"))]
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args: Vec<String> = env::args().skip(1).collect();
let mut bind = String::from("0.0.0.0");
let mut port: u16 = 8080;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--port" | "-p" => {
let Some(v) = args.get(i + 1) else {
eprintln!("--port needs a value");
return ExitCode::from(2);
};
match v.parse() {
Ok(p) => port = p,
Err(_) => {
eprintln!("invalid port: {v}");
return ExitCode::from(2);
}
}
i += 2;
}
"--bind" | "-b" => {
let Some(v) = args.get(i + 1) else {
eprintln!("--bind needs a value");
return ExitCode::from(2);
};
bind = v.clone();
i += 2;
}
"--help" | "-h" => {
print_help();
return ExitCode::SUCCESS;
}
other => {
eprintln!("unknown arg: {other}");
print_help();
return ExitCode::from(2);
}
}
}
let addr = format!("{bind}:{port}");
let server = match Server::http(&addr) {
Ok(s) => s,
Err(e) => {
eprintln!("failed to bind {addr}: {e}");
return ExitCode::from(1);
}
};
log::info!("yrxtls-serve listening on http://{addr}/");
for request in server.incoming_requests() {
if !matches!(request.method(), Method::Get | Method::Head) {
let _ = request.respond(Response::empty(StatusCode(405)));
continue;
}
let path = request.url().split('?').next().unwrap_or("/");
let route = normalize(path);
match route {
"/" | "/index.html" => respond(request, INDEX_HTML, "text/html; charset=utf-8"),
_ => {
let _ = request.respond(Response::from_string("not found").with_status_code(404));
}
}
}
ExitCode::SUCCESS
}
/// strips a trailing slash unless the path is the root, and folds repeated slashes.
#[cfg(not(target_arch = "wasm32"))]
fn normalize(path: &str) -> &str {
if path.is_empty() {
return "/";
}
if path.len() > 1 && path.ends_with('/') {
return &path[..path.len() - 1];
}
path
}
/// writes a static byte slice as a response with the supplied content-type header.
#[cfg(not(target_arch = "wasm32"))]
fn respond(request: tiny_http::Request, body: &'static [u8], content_type: &str) {
let header = match Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) {
Ok(h) => h,
Err(_) => return,
};
let cache = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=3600"[..]).unwrap();
let response = Response::from_data(body).with_header(header).with_header(cache);
let _ = request.respond(response);
}
#[cfg(not(target_arch = "wasm32"))]
fn print_help() {
eprintln!("yrxtls-serve: single-binary http server for the YrXtls visualizer bundle.");
eprintln!();
eprintln!("usage: yrxtls-serve [--bind ADDR] [--port PORT]");
eprintln!();
eprintln!(" --bind ADDR interface to listen on (default 0.0.0.0)");
eprintln!(" --port PORT port to listen on (default 8080)");
}

View File

@ -1,84 +0,0 @@
use crate::visualizer::pipeline::CepVertex;
use crate::visualizer::state::VisState;
/// emits a vertical cepstrum line plot centered in the viewport and fades the top and bottom edges.
pub fn build_cepstrum(out: &mut Vec<CepVertex>, state: &VisState, w: f32, h: f32) {
out.clear();
if state.smoothed_cepstrum.is_empty() {
return;
}
let q_start = 12_usize;
let q_end = 600_usize.min(state.smoothed_cepstrum.len());
if q_end <= q_start {
return;
}
let mut peak = 0.0_f32;
for i in q_start..q_end {
peak = peak.max(state.smoothed_cepstrum[i].abs());
}
if peak < 1e-7 {
return;
}
let inv_peak = 1.0 / peak;
let max_disp = w * 0.06;
let cx = w * 0.5;
let uc = state.unified_color;
let (cr, cg, cb_) = hsv_to_rgb(uc[0], (uc[1] * 0.7).clamp(0.0, 1.0), uc[2]);
let ca = 0.45_f32;
let fade_margin = 0.08_f32;
let edge_fade = |t: f32| -> f32 {
if t < fade_margin {
t / fade_margin
} else if t > 1.0 - fade_margin {
(1.0 - t) / fade_margin
} else {
1.0
}
};
let mut prev_x = cx + state.smoothed_cepstrum[q_start] * inv_peak * max_disp;
let mut prev_y = 0.0_f32;
let mut prev_t = 0.0_f32;
for i in q_start + 1..q_end {
let t = (i - q_start) as f32 / (q_end - q_start) as f32;
let y = t * h;
let x = cx + state.smoothed_cepstrum[i] * inv_peak * max_disp;
let a0 = ca * edge_fade(prev_t);
let a1 = ca * edge_fade(t);
out.push(CepVertex {
position: [prev_x, prev_y],
color: [cr, cg, cb_, a0],
});
out.push(CepVertex {
position: [x, y],
color: [cr, cg, cb_, a1],
});
prev_x = x;
prev_y = y;
prev_t = t;
}
}
/// converts an hsv triple in 0..1 ranges into a linear rgb triple.
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
let h = (h.fract() + 1.0).fract() * 6.0;
let i = h.floor();
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i as i32 % 6 {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
}
}

View File

@ -1,42 +0,0 @@
pub mod build;
pub mod pipeline;
pub mod state;
/// per-channel analyzer output bundle consumed by the visualizer.
#[derive(Debug, Clone, Default)]
pub struct FrameData {
pub freqs: Vec<f32>,
pub db: Vec<f32>,
pub primary_db: Vec<f32>,
pub cepstrum: Vec<f32>,
}
/// snapshot of every visualizer toggle and slider value.
#[derive(Debug, Clone, Copy)]
pub struct VizParams {
pub glass: bool,
pub entropy_on: bool,
pub entropy_strength: f32,
pub album_colors: bool,
pub mirrored: bool,
pub inverted: bool,
pub hue: f32,
pub contrast: f32,
pub brightness: f32,
}
impl Default for VizParams {
fn default() -> Self {
Self {
glass: true,
entropy_on: false,
entropy_strength: 0.0,
album_colors: false,
mirrored: true,
inverted: true,
hue: 0.9,
contrast: 1.0,
brightness: 1.0,
}
}
}

View File

@ -1,453 +0,0 @@
use bytemuck::{Pod, Zeroable};
use crate::visualizer::state::VisState;
/// integer clip rectangle for the visualizer's render pass viewport+scissor.
#[derive(Clone, Copy, Debug)]
pub struct ClipRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
/// gpu-side per-bin record consumed by the visualizer storage buffer.
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)]
pub struct BinGpu {
pub log_x: f32,
pub visual_norm: f32,
pub primary_norm: f32,
pub bright_mod: f32,
pub alpha_mod: f32,
pub hue: f32,
pub sat: f32,
pub val: f32,
}
/// uniform block holding viewport size, layout counts, render flags, and the unified glass color.
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct GlobalsGpu {
pub bounds: [f32; 2],
pub base: [f32; 2],
pub num_bins: u32,
pub num_channels: u32,
pub flags: u32,
pub fade_bins: u32,
pub hue_param: f32,
pub contrast: f32,
pub brightness: f32,
pub _pad0: f32,
pub unified_hue: f32,
pub unified_sat: f32,
pub unified_val: f32,
pub _pad1: f32,
}
/// vertex for the cepstrum line plot, carrying pixel position and rgba color.
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct CepVertex {
pub position: [f32; 2],
pub color: [f32; 4],
}
/// bitfield values packed into GlobalsGpu::flags.
pub const FLAG_GLASS: u32 = 1;
pub const FLAG_MIRRORED: u32 = 2;
pub const FLAG_INVERTED: u32 = 4;
pub const FLAG_STEREO: u32 = 8;
/// owns the wgpu render pipelines, gpu buffers, and cpu-side smoothing state.
pub struct VisPipeline {
fill_pipeline: wgpu::RenderPipeline,
line_pipeline: wgpu::RenderPipeline,
cep_pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
globals_buf: wgpu::Buffer,
bins_buf: wgpu::Buffer,
bins_capacity: u64,
cep_buf: wgpu::Buffer,
cep_capacity: u64,
pub cep_count: u32,
pub state: VisState,
pub fill_verts: u32,
pub line_verts: u32,
pub instances: u32,
pub scratch_bins: Vec<BinGpu>,
pub scratch_cep: Vec<CepVertex>,
}
const INITIAL_BINS_CAPACITY: u64 = 256 * 2;
const INITIAL_CEP_CAPACITY: u64 = 1024;
impl VisPipeline {
/// builds the three render pipelines, allocates the uniform/storage/vertex buffers, and seeds the bind group for an arbitrary color format.
pub fn for_format(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("yr_crystals.visualizer.shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("../../shaders/visualizer.wgsl").into(),
),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("yr_crystals.visualizer.bind_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("yr_crystals.visualizer.pipeline_layout"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
};
let target = wgpu::ColorTargetState {
format,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
};
let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("yr_crystals.visualizer.fill"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_fill"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(target.clone())],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("yr_crystals.visualizer.line"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_line"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(target.clone())],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let cep_attrs = [
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
},
wgpu::VertexAttribute {
offset: 8,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
];
let cep_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("yr_crystals.visualizer.cep"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_cep"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<CepVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &cep_attrs,
}],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(target)],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.visualizer.globals"),
size: std::mem::size_of::<GlobalsGpu>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bins_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.visualizer.bins"),
size: INITIAL_BINS_CAPACITY * std::mem::size_of::<BinGpu>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("yr_crystals.visualizer.bind_group"),
layout: &bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: bins_buf.as_entire_binding(),
},
],
});
let cep_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.visualizer.cep"),
size: INITIAL_CEP_CAPACITY * std::mem::size_of::<CepVertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
fill_pipeline,
line_pipeline,
cep_pipeline,
bind_group,
globals_buf,
bins_buf,
bins_capacity: INITIAL_BINS_CAPACITY,
cep_buf,
cep_capacity: INITIAL_CEP_CAPACITY,
cep_count: 0,
state: VisState::default(),
fill_verts: 0,
line_verts: 0,
instances: 0,
scratch_bins: Vec::with_capacity((INITIAL_BINS_CAPACITY) as usize),
scratch_cep: Vec::with_capacity(INITIAL_CEP_CAPACITY as usize),
}
}
}
impl VisPipeline {
/// pushes globals, scratch bins, and cepstrum vertices to the gpu, growing buffers if outgrown.
pub fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
globals: &GlobalsGpu,
num_channels: u32,
num_bins: u32,
instances: u32,
) {
queue.write_buffer(&self.globals_buf, 0, bytemuck::bytes_of(globals));
if !self.scratch_bins.is_empty() {
let needed = self.scratch_bins.len() as u64;
if needed > self.bins_capacity {
let mut new_cap = self.bins_capacity.max(1);
while new_cap < needed {
new_cap *= 2;
}
self.bins_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.visualizer.bins"),
size: new_cap * std::mem::size_of::<BinGpu>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.bins_capacity = new_cap;
self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("yr_crystals.visualizer.bind_group"),
layout: &device.create_bind_group_layout(&bind_layout_descriptor()),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.globals_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.bins_buf.as_entire_binding(),
},
],
});
}
queue.write_buffer(&self.bins_buf, 0, bytemuck::cast_slice(&self.scratch_bins));
}
self.cep_count = self.scratch_cep.len() as u32;
if !self.scratch_cep.is_empty() {
let needed = self.scratch_cep.len() as u64;
if needed > self.cep_capacity {
let mut new_cap = self.cep_capacity.max(1);
while new_cap < needed {
new_cap *= 2;
}
self.cep_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yr_crystals.visualizer.cep"),
size: new_cap * std::mem::size_of::<CepVertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.cep_capacity = new_cap;
}
queue.write_buffer(&self.cep_buf, 0, bytemuck::cast_slice(&self.scratch_cep));
}
let segs = num_bins.saturating_sub(1);
self.fill_verts = num_channels * segs * 6;
self.line_verts = num_channels * num_bins * 2;
self.instances = instances.max(1);
}
/// records a single render pass over the fills, the bin outline, and any cepstrum overlay into the clip rect.
pub fn render_into(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
clip: &ClipRect,
) {
if self.fill_verts == 0 && self.line_verts == 0 && self.cep_count == 0 {
return;
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("yr_crystals.visualizer.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
clip.x as f32,
clip.y as f32,
clip.width as f32,
clip.height as f32,
0.0,
1.0,
);
pass.set_scissor_rect(clip.x, clip.y, clip.width, clip.height);
pass.set_bind_group(0, &self.bind_group, &[]);
if self.fill_verts > 0 {
pass.set_pipeline(&self.fill_pipeline);
pass.draw(0..self.fill_verts, 0..self.instances);
}
if self.line_verts > 0 {
pass.set_pipeline(&self.line_pipeline);
pass.draw(0..self.line_verts, 0..self.instances);
}
if self.cep_count > 0 {
pass.set_pipeline(&self.cep_pipeline);
pass.set_vertex_buffer(0, self.cep_buf.slice(..));
pass.draw(0..self.cep_count, 0..1);
}
}
}
/// returns the bind-group layout that pairs the globals uniform with the bins storage buffer.
fn bind_layout_descriptor<'a>() -> wgpu::BindGroupLayoutDescriptor<'a> {
wgpu::BindGroupLayoutDescriptor {
label: Some("yr_crystals.visualizer.bind_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
}
}

View File

@ -1,445 +0,0 @@
use std::collections::VecDeque;
use crate::palette;
use crate::visualizer::pipeline::BinGpu;
use crate::visualizer::{FrameData, VizParams};
const HUE_HISTORY_LEN: usize = 40;
const HISTORY_LEN: usize = 30;
/// per-bin smoothed magnitude, modulation offsets, palette color, and recent visual history.
#[derive(Debug, Clone, Default)]
pub struct BinState {
pub visual_db: f32,
pub primary_visual_db: f32,
pub last_raw_db: f32,
pub bright_mod: f32,
pub alpha_mod: f32,
pub cached_color: [f32; 3],
pub history: VecDeque<f32>,
}
/// row of bins for one audio channel.
#[derive(Debug, Default, Clone)]
pub struct ChannelState {
pub bins: Vec<BinState>,
}
/// cpu-side smoothing state for the visualizer.
#[derive(Debug, Default)]
pub struct VisState {
pub channels: Vec<ChannelState>,
pub hue_history: VecDeque<(f32, f32)>,
pub hue_sum_cos: f32,
pub hue_sum_sin: f32,
pub unified_color: [f32; 3],
pub smoothed_cepstrum: Vec<f32>,
pub last_frames_id: usize,
}
impl VisState {
/// folds a fresh analyzer frame into the smoothed bin, hue, and cepstrum tracks.
pub fn ingest(
&mut self,
frames: &[FrameData],
frames_id: usize,
params: &VizParams,
palette: Option<&[[f32; 3]]>,
) {
self.last_frames_id = frames_id;
if self.channels.len() != frames.len() {
self.channels.resize(frames.len(), ChannelState::default());
}
if params.glass {
if let Some(f0) = frames.first() {
self.unified_color = self.update_glass_color(f0, params);
}
} else {
self.unified_color = [0.0, 0.0, 1.0];
}
for (ch_idx, frame) in frames.iter().enumerate() {
ingest_channel(&mut self.channels[ch_idx], frame, params, palette);
}
if params.mirrored {
if let Some(f0) = frames.first() {
let raw = &f0.cepstrum;
if self.smoothed_cepstrum.len() != raw.len() {
self.smoothed_cepstrum = vec![0.0; raw.len()];
}
for (i, r) in raw.iter().enumerate() {
self.smoothed_cepstrum[i] = 0.15 * r + 0.85 * self.smoothed_cepstrum[i];
}
}
}
}
/// flattens every channel's bins into the gpu-bound vector and applies a small x-shift to the right channel.
pub fn pack_bins(&self, frames: &[FrameData], stereo: bool, out: &mut Vec<BinGpu>) {
out.clear();
let n_bins = self.channels.first().map(|c| c.bins.len()).unwrap_or(0);
if n_bins == 0 {
return;
}
for (ch_idx, channel) in self.channels.iter().enumerate() {
let freqs = frames
.get(ch_idx)
.map(|f| f.freqs.as_slice())
.unwrap_or(&[]);
let x_offset = if ch_idx == 1 && stereo { 1.005 } else { 1.0 };
for (i, b) in channel.bins.iter().enumerate() {
let freq = freqs.get(i).copied().unwrap_or(0.0);
let visual_norm = ((b.visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
let primary_norm = ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0);
out.push(BinGpu {
log_x: log_x(freq * x_offset),
visual_norm,
primary_norm,
bright_mod: b.bright_mod,
alpha_mod: b.alpha_mod,
hue: b.cached_color[0],
sat: b.cached_color[1],
val: b.cached_color[2],
});
}
}
// flushes the rightmost bin to the viewport edge.
let max_x = out.iter().map(|b| b.log_x).fold(f32::NEG_INFINITY, f32::max);
if max_x.is_finite() && max_x > 0.0 {
let inv = 1.0 / max_x;
for b in out.iter_mut() {
b.log_x *= inv;
}
}
}
/// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean.
fn update_glass_color(&mut self, f0: &FrameData, params: &VizParams) -> [f32; 3] {
let mid_freq = f0
.freqs
.get(f0.freqs.len() / 2)
.copied()
.unwrap_or(1000.0);
let mean_db = if f0.db.is_empty() {
-80.0
} else {
f0.db.iter().sum::<f32>() / f0.db.len() as f32
};
let log_min = 20.0_f32.log10();
let log_max = 20_000.0_f32.log10();
let freq_norm =
(mid_freq.max(1e-9).log10() - log_min) / (log_max - log_min);
let amp_norm = ((mean_db + 80.0) / 80.0).clamp(0.0, 1.0);
let amp_weight = (1.0 / (freq_norm + 1e-4).powf(5.0) * 2.0).clamp(0.5, 6.0);
let mut hue = (freq_norm + amp_norm * amp_weight * params.hue).rem_euclid(1.0);
if params.mirrored {
hue = 1.0 - hue;
}
if hue < 0.0 {
hue += 1.0;
}
let angle = hue * std::f32::consts::TAU;
let cos_v = angle.cos();
let sin_v = angle.sin();
self.hue_history.push_back((cos_v, sin_v));
self.hue_sum_cos += cos_v;
self.hue_sum_sin += sin_v;
if self.hue_history.len() > HUE_HISTORY_LEN {
if let Some((c, s)) = self.hue_history.pop_front() {
self.hue_sum_cos -= c;
self.hue_sum_sin -= s;
}
}
let smoothed_angle = self.hue_sum_sin.atan2(self.hue_sum_cos);
let mut smoothed_hue = smoothed_angle / std::f32::consts::TAU;
if smoothed_hue < 0.0 {
smoothed_hue += 1.0;
}
[smoothed_hue, 1.0, 1.0]
}
}
/// maps a frequency in hertz to a 0..1 horizontal position on the 20 hz to 20 khz log axis.
fn log_x(freq: f32) -> f32 {
let log_min = 20.0_f32.log10();
let log_max = 20_000.0_f32.log10();
if freq <= 0.0 {
return 0.0;
}
(freq.max(1e-9).log10() - log_min) / (log_max - log_min)
}
/// updates one channel's bin smoothing, peak modulations, treble compensation, and palette colors.
fn ingest_channel(
channel: &mut ChannelState,
frame: &FrameData,
params: &VizParams,
palette: Option<&[[f32; 3]]>,
) {
let n = frame.db.len();
if channel.bins.len() != n {
channel.bins.resize(n, BinState::default());
}
if n == 0 {
return;
}
let use_entropy = params.entropy_on;
let mut bin_entropy = vec![0.0_f32; n];
if use_entropy {
for (i, b) in channel.bins.iter().enumerate() {
bin_entropy[i] = calculate_entropy(&b.history);
}
}
let median_entropy = if use_entropy {
median_of(&bin_entropy)
} else {
0.0
};
for (i, b) in channel.bins.iter_mut().enumerate() {
let raw = frame.db[i];
let primary = frame.primary_db.get(i).copied().unwrap_or(raw);
let change = raw - b.visual_db;
if use_entropy {
let relative = median_entropy - bin_entropy[i];
let base = 1.5_f32;
let reward_gain = base + params.entropy_strength;
let penalty_gain = base - params.entropy_strength;
let gain = if relative >= 0.0 { reward_gain } else { penalty_gain };
let multiplier = (1.0 + relative * gain * 2.0).clamp(0.05, 4.0);
b.visual_db += change * multiplier;
b.history.push_back(b.visual_db);
while b.history.len() > HISTORY_LEN {
b.history.pop_front();
}
} else {
let resp = 0.2_f32;
b.visual_db = b.visual_db * (1.0 - resp) + raw * resp;
b.history.push_back(b.visual_db);
while b.history.len() > HISTORY_LEN {
b.history.pop_front();
}
}
let pattern_resp = 0.1_f32;
b.primary_visual_db = b.primary_visual_db * (1.0 - pattern_resp) + primary * pattern_resp;
b.last_raw_db = raw;
}
let mut vertex_energy: Vec<f32> = channel
.bins
.iter()
.map(|b| ((b.primary_visual_db + 80.0) / 80.0).clamp(0.0, 1.0))
.collect();
let split = n / 2;
let mut max_low = 0.01_f32;
let mut max_high = 0.01_f32;
for v in vertex_energy.iter().take(split) {
max_low = max_low.max(*v);
}
for v in vertex_energy.iter().skip(split) {
max_high = max_high.max(*v);
}
let treble_boost = (max_low / max_high).clamp(1.0, 40.0);
let mut global_max = 0.001_f32;
for (j, v) in vertex_energy.iter_mut().enumerate() {
if j >= split {
let t = (j - split) as f32 / (n - split) as f32;
*v *= 1.0 + (treble_boost - 1.0) * t;
}
let compressed = v.tanh();
*v = compressed;
if compressed > global_max {
global_max = compressed;
}
}
for v in vertex_energy.iter_mut() {
*v = (*v / global_max).clamp(0.0, 1.0);
}
for b in channel.bins.iter_mut() {
b.bright_mod = 0.0;
b.alpha_mod = 0.0;
}
let entropy_factor = if use_entropy {
params.entropy_strength.abs().max(0.1)
} else {
1.0
};
if n >= 3 {
for i in 1..n - 1 {
let curr = vertex_energy[i];
let prev = vertex_energy[i - 1];
let next = vertex_energy[i + 1];
if curr > prev && curr > next {
let left_dominant = prev > next;
let sharpness = (curr - prev).min(curr - next);
let peak_intensity =
(sharpness * 10.0 * entropy_factor).powf(0.3).clamp(0.0, 1.0);
let decay_base = 0.65 - (sharpness * 3.0).clamp(0.0, 0.35);
for d in 1..=12_i32 {
apply_pattern(&mut channel.bins, i, d, left_dominant, -1, peak_intensity, decay_base);
apply_pattern(&mut channel.bins, i, d, !left_dominant, 1, peak_intensity, decay_base);
}
}
}
}
let use_palette = params.album_colors
&& palette
.map(|p| !p.is_empty())
.unwrap_or(false);
let denom = (n as f32 - 1.0).max(1.0);
for (i, b) in channel.bins.iter_mut().enumerate() {
if use_palette {
let pal = palette.unwrap();
let plen = pal.len();
let raw_idx = if plen >= n {
i * (plen - 1) / (n - 1).max(1)
} else {
i * plen / n.max(1)
};
let pal_idx = if params.mirrored {
plen.saturating_sub(1).saturating_sub(raw_idx)
} else {
raw_idx
}
.min(plen - 1);
let rgb = pal[pal_idx];
let (h, s, v) = palette::rgb_to_hsv(rgb[0], rgb[1], rgb[2]);
b.cached_color = [h, s, v];
} else {
let mut hue = i as f32 / denom;
if params.mirrored {
hue = 1.0 - hue;
}
b.cached_color = [hue, 1.0, 1.0];
}
}
}
/// stamps a three-step bright/alpha modulation pattern outward from a peak bin, decaying with distance.
fn apply_pattern(
bins: &mut [BinState],
centre: usize,
dist: i32,
is_bright_side: bool,
direction: i32,
peak_intensity: f32,
decay_base: f32,
) {
let target = if direction == -1 {
centre as isize - dist as isize
} else {
centre as isize + dist as isize - 1
};
if target < 0 || target as usize >= bins.len() {
return;
}
let cycle = (dist - 1) / 3;
let step = (dist - 1) % 3;
let decay = decay_base.powi(cycle);
let intensity = peak_intensity * decay;
if intensity < 0.01 {
return;
}
let mut ty = step;
if is_bright_side {
ty = (ty + 2) % 3;
}
let bin = &mut bins[target as usize];
match ty {
0 => {
bin.bright_mod += 0.8 * intensity;
bin.alpha_mod -= 0.8 * intensity;
}
1 => {
bin.bright_mod -= 0.8 * intensity;
bin.alpha_mod += 0.2 * intensity;
}
_ => {
bin.bright_mod += 0.8 * intensity;
bin.alpha_mod += 0.2 * intensity;
}
}
}
/// scores deviation of a bin's recent history from a low-frequency reconstruction as the entropy proxy.
fn calculate_entropy(history: &VecDeque<f32>) -> f32 {
let buf: Vec<f32> = history.iter().copied().collect();
let n = buf.len();
if n < 4 {
return 0.0;
}
let nf = n as f64;
let mut x_re = vec![0.0_f64; n];
let mut x_im = vec![0.0_f64; n];
for k in 0..n {
let mut re = 0.0;
let mut im = 0.0;
for (idx, &h) in buf.iter().enumerate() {
let angle = -2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
re += h as f64 * angle.cos();
im += h as f64 * angle.sin();
}
x_re[k] = re;
x_im[k] = im;
}
for k in (n / 2 + 1)..n {
x_re[k] = 0.0;
x_im[k] = 0.0;
}
for k in 1..n.div_ceil(2) {
x_re[k] *= 2.0;
x_im[k] *= 2.0;
}
let mut sq_sum = 0.0_f64;
for idx in 0..n {
let mut im = 0.0;
for k in 0..n {
let angle = 2.0 * std::f64::consts::PI * k as f64 * idx as f64 / nf;
im += x_re[k] * angle.sin() + x_im[k] * angle.cos();
}
im /= nf;
sq_sum += im * im;
}
((sq_sum / nf).sqrt() as f32 / 10.0).clamp(0.0, 1.0)
}
/// returns the median element via a partial selection sort over a copy.
fn median_of(values: &[f32]) -> f32 {
if values.is_empty() {
return 0.0;
}
let mut v = values.to_vec();
let mid = v.len() / 2;
v.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v[mid]
}

View File

@ -1,219 +0,0 @@
/* desktop */
.viz-desktop.viz-wrap {
position: relative;
margin: 1rem 0 4rem;
width: 100vw;
left: 50%;
transform: translateX(-50%);
line-height: 0;
}
.viz-desktop .viz-canvas-wrap {
position: relative;
line-height: 0;
min-width: 0;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.25);
}
.viz-desktop .viz-canvas-wrap canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
.viz-desktop .viz-tracks {
position: absolute;
top: 0.6rem;
left: 0.6rem;
bottom: 3.5rem;
width: 13rem;
display: flex;
flex-direction: column;
overflow-y: auto;
padding-right: 0.4rem;
z-index: 2;
}
.viz-desktop .viz-tracks::-webkit-scrollbar { width: 4px; }
.viz-desktop .viz-tracks::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
.viz-desktop .viz-track {
font: inherit;
font-size: 0.8rem;
line-height: 1.45;
text-align: left;
padding: 0.1rem 0.5rem;
border: none;
background: transparent;
color: rgba(200, 200, 200, 0.4);
cursor: pointer;
transition: color 120ms ease;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.viz-desktop .viz-track:hover { color: rgba(235, 235, 235, 0.85); }
.viz-desktop .viz-track.active { color: #fff; }
.viz-desktop .viz-vol {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 3;
}
.viz-desktop .viz-vol .viz-prompt { display: none; }
.viz-desktop .viz-vol.initial {
top: 50%;
left: 50%;
right: auto;
transform: translate(-50%, -50%);
width: auto;
height: auto;
padding: 1.1rem 1.6rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
gap: 1rem;
}
.viz-desktop .viz-vol.initial img { width: 3rem; height: 3rem; }
.viz-desktop .viz-vol.initial .viz-prompt {
display: inline-block;
font: inherit;
font-size: 1.15rem;
color: #fff;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
pointer-events: none;
}
.viz-desktop .viz-transport {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
z-index: 2;
opacity: 1;
transition: opacity 400ms ease;
}
.viz-desktop .viz-transport.fade { opacity: 0; }
.viz-desktop .viz-ctrl {
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.35);
color: rgba(220, 220, 220, 0.85);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 120ms ease, opacity 400ms ease;
opacity: 1;
}
.viz-desktop .viz-ctrl.fade { opacity: 0; }
.viz-desktop .viz-ctrl:hover { background: rgba(0, 0, 0, 0.55); }
.viz-desktop .viz-ctrl img { width: 58%; height: 58%; display: block; pointer-events: none; user-select: none; }
.viz-desktop .viz-ctrl-play img { width: 50%; height: 50%; }
/* mobile */
.viz-mobile.viz-wrap {
position: relative;
margin: 1rem 0 2rem;
width: 100vw;
left: 50%;
transform: translateX(-50%);
line-height: 0;
}
.viz-mobile .viz-canvas-wrap {
position: relative;
line-height: 0;
min-width: 0;
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
}
.viz-mobile .viz-canvas-wrap canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}
.viz-mobile .viz-vol {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 3;
}
.viz-mobile .viz-vol .viz-prompt { display: none; }
.viz-mobile .viz-vol.initial {
top: 50%;
left: 50%;
right: auto;
transform: translate(-50%, -50%);
width: auto;
height: auto;
padding: 0.85rem 1.2rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
gap: 0.75rem;
}
.viz-mobile .viz-vol.initial img { width: 2.2rem; height: 2.2rem; }
.viz-mobile .viz-vol.initial .viz-prompt {
display: inline-block;
font: inherit;
font-size: 0.95rem;
color: #fff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
pointer-events: none;
white-space: normal;
max-width: 60vw;
}
.viz-mobile .viz-transport {
position: absolute;
bottom: 0.6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
z-index: 2;
opacity: 1;
transition: opacity 400ms ease;
}
.viz-mobile .viz-transport.fade { opacity: 0; }
.viz-mobile .viz-ctrl {
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.4);
color: rgba(230, 230, 230, 0.9);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 120ms ease, opacity 400ms ease;
opacity: 1;
}
.viz-mobile .viz-ctrl.fade { opacity: 0; }
.viz-mobile .viz-ctrl:active { background: rgba(0, 0, 0, 0.6); }
.viz-mobile .viz-ctrl img { width: 58%; height: 58%; display: block; pointer-events: none; user-select: none; }
.viz-mobile .viz-ctrl-play img { width: 50%; height: 50%; }
yrxtals-tracks {
display: block;
max-width: 92vw;
margin: 1rem auto 3rem;
padding: 0 0.5rem;
}
yrxtals-tracks .viz-track {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: 1rem;
line-height: 1.6;
padding: 0.4rem 0.75rem;
border: none;
background: transparent;
color: rgba(220, 220, 220, 0.7);
cursor: pointer;
transition: color 120ms ease;
}
yrxtals-tracks .viz-track:hover { color: rgba(245, 245, 245, 0.95); }
yrxtals-tracks .viz-track.active { color: #fff; }

View File

@ -1,389 +0,0 @@
// visualizer embed for else-if.org/yr_xtals.
(function () {
const HOST = 'https://files.else-if.org/f/YrXtals/';
const VIS_MODULE = HOST + 'yr_crystals_web.js';
const ALBUM_FOLDER = HOST + 'Knives_For_Cutting_Corners/';
const ICON = {
play: HOST + 'assets/Play.svg',
pause: HOST + 'assets/Pause.svg',
bskip: HOST + 'assets/BSkip.svg',
fskip: HOST + 'assets/FSkip.svg',
mute: HOST + 'assets/Mute.svg',
unmute: HOST + 'assets/Unmute.svg',
};
const ANCHOR_SELECTOR = 'yrxtals';
const TRACKS_HOST_SELECTOR = 'yrxtals-tracks';
const ASPECT_W = 16;
const ASPECT_H = 9;
const NUM_BINS = 26;
const DEFAULT_FFT = 16384;
const DEFAULT_HOP = 4096;
const TRACK_PARAMS = {
'bouncy castle': { fft: 16384, hop: 2048 },
'curled': { fft: 8192, hop: 2048 },
'eeger': { fft: 16384, hop: 2048 },
'fickle': { fft: 8192, hop: 2048 },
'fire sale': { fft: 16384, hop: 2048 },
'friik': { fft: 16384, hop: 2048 },
'gourded': { fft: 16384, hop: 2048 },
'moron': { fft: 16384, hop: 2048 },
'never give an angel a front': { fft: 8192, hop: 4096 },
'now youre speaking my language': { fft: 16384, hop: 2048 },
'ornery': { fft: 16384, hop: 2048 },
'quicksand': { fft: 16384, hop: 2048 },
'stolen art': { fft: 8192, hop: 2048 },
'them bunch': { fft: 8192, hop: 2048 },
'twig': { fft: 16384, hop: 2048 },
'we that borrowed': { fft: 16384, hop: 2048 },
};
const LOG_MIN = Math.log10(40);
const LOG_MAX = Math.log10(11000);
const CTRL_FADE_DELAY_MS = 1500;
const CTRL_FADE_DURATION_MS = 400;
const RESTART_THRESHOLD_S = 3;
function isMobileOrTablet() {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
if (/Mobi|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|webOS/i.test(ua)) return true;
if (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) return true;
return false;
}
// builds an img element for one of the asset URLs.
function iconImg(src) {
const img = document.createElement('img');
img.src = src;
img.draggable = false;
return img;
}
function start() {
const anchor = document.querySelector(ANCHOR_SELECTOR);
if (!anchor) return;
swap(anchor);
}
function swap(anchor) {
const mobile = isMobileOrTablet();
const wrap = document.createElement('div');
wrap.className = 'viz-wrap ' + (mobile ? 'viz-mobile' : 'viz-desktop');
const canvasWrap = document.createElement('div');
canvasWrap.className = 'viz-canvas-wrap';
const canvas = document.createElement('canvas');
let externalTracksHost = document.querySelector(TRACKS_HOST_SELECTOR);
if (externalTracksHost && !mobile) {
externalTracksHost.remove();
externalTracksHost = null;
}
const tracksEl = externalTracksHost || document.createElement('div');
if (!externalTracksHost) {
tracksEl.className = 'viz-tracks';
}
const volBtn = document.createElement('button');
volBtn.className = 'viz-ctrl viz-vol initial';
volBtn.setAttribute('aria-label', 'unmute');
volBtn.appendChild(iconImg(ICON.mute));
const promptText = document.createElement('span');
promptText.className = 'viz-prompt';
promptText.textContent = 'Unmute the audio to begin the visualizer to my music!';
volBtn.appendChild(promptText);
const transportEl = document.createElement('div');
transportEl.className = 'viz-transport';
const backBtn = document.createElement('button');
backBtn.className = 'viz-ctrl';
backBtn.setAttribute('aria-label', 'previous track');
backBtn.appendChild(iconImg(ICON.bskip));
const playBtn = document.createElement('button');
playBtn.className = 'viz-ctrl viz-ctrl-play';
playBtn.setAttribute('aria-label', 'play');
playBtn.appendChild(iconImg(ICON.play));
const fwdBtn = document.createElement('button');
fwdBtn.className = 'viz-ctrl';
fwdBtn.setAttribute('aria-label', 'next track');
fwdBtn.appendChild(iconImg(ICON.fskip));
transportEl.appendChild(backBtn);
transportEl.appendChild(playBtn);
transportEl.appendChild(fwdBtn);
canvasWrap.appendChild(canvas);
canvasWrap.appendChild(volBtn);
canvasWrap.appendChild(transportEl);
wrap.appendChild(canvasWrap);
if (!externalTracksHost) {
wrap.appendChild(tracksEl);
}
anchor.replaceWith(wrap);
let vizRef = null;
const fit = () => {
const r = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(r.width * dpr));
const h = Math.max(1, Math.floor(r.height * dpr));
canvas.width = w;
canvas.height = h;
if (vizRef && vizRef.resize) vizRef.resize(w, h);
};
fit();
new ResizeObserver(fit).observe(canvas);
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', fit);
import(VIS_MODULE)
.then(mod => mod.mount(canvas))
.then(viz => {
vizRef = viz;
fit();
bootAudio({ tracksEl, volBtn, transportEl, backBtn, playBtn, fwdBtn }, viz);
})
.catch(err => console.error('[yrxtls] mount failed:', err));
}
async function bootAudio(ui, viz) {
let html;
try {
html = await (await fetch(ALBUM_FOLDER)).text();
} catch (e) {
console.error('[yrxtls] folder fetch failed:', e);
return;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const cards = [...doc.querySelectorAll('.folder-card-wrap[data-url$=".mp3"]')];
if (cards.length === 0) return;
const tracks = cards
.map(c => {
const url = c.dataset.url;
const stem = decodeURIComponent(url.split('/').pop().replace(/\.mp3$/i, ''));
return { url, name: stem.replace(/[_-]+/g, ' ') };
})
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.preload = 'auto';
audio.muted = true;
// log-spaced band edges in Hz.
const binEdges = new Float32Array(NUM_BINS + 1);
for (let i = 0; i <= NUM_BINS; i++) {
const t = i / NUM_BINS;
binEdges[i] = Math.pow(10, LOG_MIN + (LOG_MAX - LOG_MIN) * t);
}
const outBins = new Float32Array(NUM_BINS);
let audioCtx = null;
let srcNode = null;
let analyser = null;
let gainNode = null;
let fftBuf = null;
let isMuted = true;
let lastSampleTime = -1;
let currentIndex = -1;
let currentHop = DEFAULT_HOP;
// idempotently builds the analyser plus gain graph against the media element.
function ensureGraph() {
if (audioCtx) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
audioCtx = new Ctor();
srcNode = audioCtx.createMediaElementSource(audio);
analyser = audioCtx.createAnalyser();
analyser.fftSize = DEFAULT_FFT;
analyser.smoothingTimeConstant = 0.2;
gainNode = audioCtx.createGain();
gainNode.gain.value = isMuted ? 0 : 1;
srcNode.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(audioCtx.destination);
audio.muted = false;
fftBuf = new Float32Array(analyser.frequencyBinCount);
if (audioCtx.state === 'suspended') audioCtx.resume();
requestAnimationFrame(pumpFrames);
}
// rAF-driven FFT pump gated to one read per currentHop interval.
function pumpFrames() {
if (!analyser) return;
const now = audioCtx.currentTime;
const hopSec = currentHop / audioCtx.sampleRate;
if (lastSampleTime < 0 || now - lastSampleTime >= hopSec) {
lastSampleTime = now;
analyser.getFloatFrequencyData(fftBuf);
const binHz = audioCtx.sampleRate / analyser.fftSize;
for (let b = 0; b < NUM_BINS; b++) {
const loIdx = Math.max(0, Math.floor(binEdges[b] / binHz));
const hiIdx = Math.min(fftBuf.length - 1, Math.ceil(binEdges[b + 1] / binHz));
let peak = -200;
for (let k = loIdx; k <= hiIdx; k++) {
if (fftBuf[k] > peak) peak = fftBuf[k];
}
outBins[b] = Math.max(-80, Math.min(0, peak));
}
if (viz && viz.pushBins) viz.pushBins(outBins);
}
requestAnimationFrame(pumpFrames);
}
const pills = [];
tracks.forEach((track, idx) => {
const btn = document.createElement('button');
btn.className = 'viz-track';
btn.textContent = track.name;
btn.addEventListener('click', () => {
if (currentIndex === idx && !audio.paused) {
audio.pause();
return;
}
loadAndPlay(idx);
});
pills.push(btn);
ui.tracksEl.appendChild(btn);
});
// applies the per-track FFT and hop override, falling back to defaults on no match.
function normalizeTrackName(s) {
return (s || '').toLowerCase().replace(/['"]/g, '').replace(/[_\-\s]+/g, ' ').trim();
}
const TRACK_PARAMS_NORM = {};
for (const k of Object.keys(TRACK_PARAMS)) {
TRACK_PARAMS_NORM[normalizeTrackName(k)] = TRACK_PARAMS[k];
}
function applyTrackParams(name) {
const key = normalizeTrackName(name);
const hit = TRACK_PARAMS_NORM[key];
const params = hit || { fft: DEFAULT_FFT, hop: DEFAULT_HOP };
console.log('[yrxtls] track', JSON.stringify(name), 'key', JSON.stringify(key), 'params', params, hit ? '(override)' : '(default)');
currentHop = params.hop;
if (analyser && srcNode && gainNode && analyser.fftSize !== params.fft) {
const smoothing = analyser.smoothingTimeConstant;
try { srcNode.disconnect(analyser); } catch (e) {}
try { analyser.disconnect(gainNode); } catch (e) {}
analyser = audioCtx.createAnalyser();
analyser.fftSize = params.fft;
analyser.smoothingTimeConstant = smoothing;
srcNode.connect(analyser);
analyser.connect(gainNode);
fftBuf = new Float32Array(analyser.frequencyBinCount);
console.log('[yrxtls] analyser recreated at fftSize', analyser.fftSize, 'bins', analyser.frequencyBinCount);
}
}
// starts playback of the indexed track with wrap-around on out-of-range index.
function loadAndPlay(idx) {
if (tracks.length === 0) return;
const wrapped = ((idx % tracks.length) + tracks.length) % tracks.length;
currentIndex = wrapped;
pills.forEach((p, i) => p.classList.toggle('active', i === wrapped));
const active = pills[wrapped];
if (active && active.scrollIntoView) {
active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
audio.src = tracks[wrapped].url;
ensureGraph();
applyTrackParams(tracks[wrapped].name);
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
}
function updatePlayIcon() {
const playing = !!audio.src && !audio.paused;
ui.playBtn.firstChild.src = playing ? ICON.pause : ICON.play;
ui.playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
}
ui.backBtn.addEventListener('click', () => {
if (currentIndex < 0) { loadAndPlay(0); return; }
if (!audio.paused && audio.currentTime > RESTART_THRESHOLD_S) {
audio.currentTime = 0;
} else {
loadAndPlay(currentIndex - 1);
}
});
ui.fwdBtn.addEventListener('click', () => {
loadAndPlay(currentIndex < 0 ? 0 : currentIndex + 1);
});
ui.playBtn.addEventListener('click', () => {
if (currentIndex < 0) { loadAndPlay(0); return; }
if (audio.paused) {
ensureGraph();
audio.play().catch(err => console.error('[yrxtls] play failed:', err));
} else {
audio.pause();
}
});
audio.addEventListener('play', updatePlayIcon);
audio.addEventListener('pause', updatePlayIcon);
audio.addEventListener('ended', () => {
if (currentIndex >= 0 && currentIndex < tracks.length - 1) {
loadAndPlay(currentIndex + 1);
} else {
pills.forEach(p => p.classList.remove('active'));
currentIndex = -1;
updatePlayIcon();
}
});
let fadeTimer = null;
// reveals overlay controls and arms the idle-fade timer.
function showControls() {
ui.volBtn.classList.remove('fade');
ui.transportEl.classList.remove('fade');
if (fadeTimer) { clearTimeout(fadeTimer); fadeTimer = null; }
fadeTimer = setTimeout(() => {
ui.transportEl.classList.add('fade');
if (!isMuted) ui.volBtn.classList.add('fade');
}, CTRL_FADE_DELAY_MS);
}
function setMutedState(muted) {
isMuted = muted;
const iconEl = ui.volBtn.querySelector('img');
if (iconEl) iconEl.src = muted ? ICON.mute : ICON.unmute;
ui.volBtn.setAttribute('aria-label', muted ? 'unmute' : 'mute');
if (gainNode) gainNode.gain.value = muted ? 0 : 1;
showControls();
}
// resolves the seed track index by name match, falling back to alphabetical index zero.
function seedTrackIndex() {
const target = 'it caves in';
const idx = tracks.findIndex(t => t.name.trim().toLowerCase() === target);
return idx >= 0 ? idx : 0;
}
ui.volBtn.addEventListener('click', () => {
const wasInitial = ui.volBtn.classList.contains('initial');
if (wasInitial) {
ui.volBtn.classList.remove('initial');
loadAndPlay(seedTrackIndex());
ensureGraph();
setMutedState(false);
return;
}
if (!audio.src && tracks.length > 0) {
loadAndPlay(0);
}
ensureGraph();
setMutedState(!isMuted);
});
document.addEventListener('mousemove', showControls);
document.addEventListener('touchstart', showControls, { passive: true });
showControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();

View File

@ -4,7 +4,7 @@ use std::env;
use std::path::PathBuf;
use std::process::{Command, ExitCode};
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android", "web"];
const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux", "ios", "android"];
/// dispatches a cargo xtask sub-command to the matching platform script under scripts/.
fn main() -> ExitCode {
@ -19,8 +19,6 @@ fn main() -> ExitCode {
let extra_args: Vec<&String> = args.iter().skip(1).collect();
let (action, platform) = if cmd == "release-playstore" {
("release-playstore".to_string(), "android".to_string())
} else if cmd == "web" {
("build".to_string(), "web".to_string())
} else {
parse(cmd)
};
@ -41,7 +39,7 @@ fn main() -> ExitCode {
"-File",
],
),
"linux" | "macos" | "ios" | "android" | "web" => (
"linux" | "macos" | "ios" | "android" => (
repo_root.join(format!("scripts/{platform}/{action}.sh")),
vec!["bash"],
),
@ -118,10 +116,6 @@ fn print_help() {
eprintln!(" build release build for the current platform");
eprintln!(" install release build + install (macOS: /Applications)");
eprintln!(" debug debug build + foreground launch (live console on iOS)");
eprintln!(" package cross-compile + zip distributables (macOS host only)");
eprintln!(" --all all six desktop targets");
eprintln!(" --target <name> e.g. macos-aarch64, windows-x86_64, linux-aarch64");
eprintln!(" web build the WASM bundle and yrxtls-serve binary into web/dist/");
eprintln!(" select-ios pick a physical device or simulator interactively");
eprintln!(" select-android pick an attached Android device by adb serial");
eprintln!(" bootstrap-android install android sdk packages from .android-sdk-packages");